mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-12 06:16:10 +00:00
android: refactor AACP and add autoconnect based on BLE broadcasts
This commit is contained in:
@@ -61,5 +61,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.haze)
|
implementation(libs.haze)
|
||||||
implementation(libs.haze.materials)
|
implementation(libs.haze.materials)
|
||||||
|
implementation(libs.androidx.dynamicanimation)
|
||||||
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -72,16 +75,12 @@ import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedBu
|
|||||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class DismissAnimationValues(
|
|
||||||
val offsetY: Dp = 0.dp,
|
|
||||||
val scale: Float = 1f,
|
|
||||||
val alpha: Float = 1f
|
|
||||||
)
|
|
||||||
|
|
||||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||||
|
|
||||||
private var airPodsService: AirPodsService? = null
|
private var airPodsService: AirPodsService? = null
|
||||||
@@ -114,7 +113,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +157,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
|
|||||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,6 @@ fun DraggableDismissBox(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var isDragging by remember { mutableStateOf(false) }
|
var isDragging by remember { mutableStateOf(false) }
|
||||||
@@ -218,7 +214,6 @@ fun DraggableDismissBox(
|
|||||||
|
|
||||||
LaunchedEffect(dragOffset, isDragging) {
|
LaunchedEffect(dragOffset, isDragging) {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
val dragDirection = if (dragOffset > 0) 1f else -1f
|
|
||||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||||
|
|
||||||
animatedOffset.snapTo(dragOffset)
|
animatedOffset.snapTo(dragOffset)
|
||||||
@@ -285,6 +280,7 @@ fun DraggableDismissBox(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
@Composable
|
@Composable
|
||||||
fun NewControlCenterDialogContent(
|
fun NewControlCenterDialogContent(
|
||||||
service: AirPodsService?,
|
service: AirPodsService?,
|
||||||
@@ -353,7 +349,7 @@ fun NewControlCenterDialogContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
service?.let {
|
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 }
|
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||||
if (!availableModes.contains(initialMode)) {
|
if (!availableModes.contains(initialMode)) {
|
||||||
initialMode = NoiseControlMode.TRANSPARENCY
|
initialMode = NoiseControlMode.TRANSPARENCY
|
||||||
@@ -482,7 +478,10 @@ fun NewControlCenterDialogContent(
|
|||||||
availableModes = availableModes,
|
availableModes = availableModes,
|
||||||
selectedMode = currentAncMode,
|
selectedMode = currentAncMode,
|
||||||
onModeSelected = { newMode ->
|
onModeSelected = { newMode ->
|
||||||
service.setANCMode(newMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
value = newMode.ordinal + 1
|
||||||
|
)
|
||||||
currentAncMode = newMode
|
currentAncMode = newMode
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(0.8f)
|
modifier = Modifier.fillMaxWidth(0.8f)
|
||||||
@@ -560,7 +559,10 @@ fun NewControlCenterDialogContent(
|
|||||||
.clickable(
|
.clickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newState = !isConvAwarenessEnabled
|
val newState = !isConvAwarenessEnabled
|
||||||
service.setCAEnabled(newState)
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
value = newState
|
||||||
|
)
|
||||||
isConvAwarenessEnabled = newState
|
isConvAwarenessEnabled = newState
|
||||||
},
|
},
|
||||||
indication = null,
|
indication = null,
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
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
|
@Composable
|
||||||
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AccessibilitySettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.accessibility).uppercase(),
|
text = stringResource(R.string.accessibility).uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
|
val pressSpeedOptions = mapOf(
|
||||||
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
|
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(
|
DropdownMenuComponent(
|
||||||
label = "Press Speed",
|
label = "Press Speed",
|
||||||
options = pressSpeedOptions,
|
options = pressSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedPressSpeed,
|
selectedOption = selectedPressSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressSpeed = it
|
selectedPressSpeed = newValue
|
||||||
service.setPressSpeed(pressSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
|
||||||
|
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
|
val pressAndHoldDurationOptions = mapOf(
|
||||||
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
|
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(
|
DropdownMenuComponent(
|
||||||
label = "Press and Hold Duration",
|
label = "Press and Hold Duration",
|
||||||
options = pressAndHoldDurationOptions,
|
options = pressAndHoldDurationOptions.values.toList(),
|
||||||
selectedOption = selectedPressAndHoldDuration,
|
selectedOption = selectedPressAndHoldDuration.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedPressAndHoldDuration = it
|
selectedPressAndHoldDuration = newValue
|
||||||
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
|
||||||
|
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
|
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
|
||||||
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
|
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(
|
DropdownMenuComponent(
|
||||||
label = "Volume Swipe Speed",
|
label = "Volume Swipe Speed",
|
||||||
options = volumeSwipeSpeedOptions,
|
options = volumeSwipeSpeedOptions.values.toList(),
|
||||||
selectedOption = selectedVolumeSwipeSpeed,
|
selectedOption = selectedVolumeSwipeSpeed.toString(),
|
||||||
onOptionSelected = {
|
onOptionSelected = { newValue ->
|
||||||
selectedVolumeSwipeSpeed = it
|
selectedVolumeSwipeSpeed = newValue
|
||||||
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
|
service.aacpManager.sendControlCommand(
|
||||||
|
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
|
||||||
|
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
|
||||||
|
)
|
||||||
},
|
},
|
||||||
textColor = textColor
|
textColor = textColor
|
||||||
)
|
)
|
||||||
|
|
||||||
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
|
SinglePodANCSwitch()
|
||||||
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
|
VolumeControlSwitch()
|
||||||
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettingsPreview() {
|
fun AccessibilitySettingsPreview() {
|
||||||
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AccessibilitySettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AdaptiveStrengthSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val sliderValue = remember { mutableFloatStateOf(0f) }
|
||||||
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
LaunchedEffect(sliderValue) {
|
||||||
if (sharedPreferences.contains("adaptive_strength")) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
|
||||||
}
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
|
||||||
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthSliderPreview() {
|
fun AdaptiveStrengthSliderPreview() {
|
||||||
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AdaptiveStrengthSlider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun AudioSettings() {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
|
|||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
|
ConversationalAwarenessSwitch()
|
||||||
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioSettingsPreview() {
|
fun AudioSettingsPreview() {
|
||||||
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
|
AudioSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
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.Battery
|
||||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -88,7 +88,7 @@ fun ControlCenterNoiseControlSegmentedButton(
|
|||||||
) {
|
) {
|
||||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
var iconRowWidthPx by remember { mutableStateOf(0f) }
|
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||||
val itemCount = availableModes.size
|
val itemCount = availableModes.size
|
||||||
|
|
||||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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
|
@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 {
|
var conversationalAwarenessEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("conversational_awareness", true)
|
conversationEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConversationalAwareness(enabled: Boolean) {
|
fun updateConversationalAwareness(enabled: Boolean) {
|
||||||
conversationalAwarenessEnabled = enabled
|
conversationalAwarenessEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setCAEnabled(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ConversationalAwarenessSwitchPreview() {
|
fun ConversationalAwarenessSwitchPreview() {
|
||||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
ConversationalAwarenessSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable
|
@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 isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val snakeCasedName =
|
||||||
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
|
||||||
var checked by remember { mutableStateOf(default) }
|
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)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
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) {
|
LaunchedEffect(sharedPreferences) {
|
||||||
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
checked = sharedPreferences.getBoolean(snakeCasedName, true)
|
||||||
}
|
}
|
||||||
@@ -73,14 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
},
|
},
|
||||||
onTap = {
|
onTap = {
|
||||||
checked = !checked
|
checked = !checked
|
||||||
sharedPreferences
|
cb()
|
||||||
.edit()
|
|
||||||
.putBoolean(snakeCasedName, checked)
|
|
||||||
.apply()
|
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method = service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, checked)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -98,12 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
checked = it
|
checked = it
|
||||||
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
|
cb()
|
||||||
if (functionName != null && service != null) {
|
|
||||||
val method =
|
|
||||||
service::class.java.getMethod(functionName, Boolean::class.java)
|
|
||||||
method.invoke(service, it)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var loudSoundReductionEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("loud_sound_reduction", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLoudSoundReduction(enabled: Boolean) {
|
|
||||||
loudSoundReductionEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
|
|
||||||
service.setLoudSoundReduction(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updateLoudSoundReduction(!loudSoundReductionEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Loud Sound Reduction",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Reduces loud sounds you are exposed to.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = loudSoundReductionEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updateLoudSoundReduction(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun LoudSoundReductionSwitchPreview() {
|
|
||||||
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -23,7 +25,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.animation.core.AnimationSpec
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
import androidx.compose.material3.VerticalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -74,35 +74,34 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettings(
|
fun NoiseControlSettings(
|
||||||
service: AirPodsService,
|
service: AirPodsService,
|
||||||
onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
|
||||||
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
|
||||||
val preferenceChangeListener = remember {
|
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
|
||||||
if (key == "off_listening_mode") {
|
val offListeningModeListener = object: AACPManager.ControlCommandListener {
|
||||||
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
|
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||||
}
|
offListeningMode.value = controlCommand.value[0] == 1.toByte()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
|
|
||||||
onDispose {
|
|
||||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service.aacpManager.registerControlCommandListener(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||||
|
offListeningModeListener
|
||||||
|
)
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
@@ -116,27 +115,21 @@ fun NoiseControlSettings(
|
|||||||
val d3a = remember { mutableFloatStateOf(0f) }
|
val d3a = remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
|
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) {
|
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
|
||||||
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
|
NoiseControlMode.TRANSPARENCY
|
||||||
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
|
|
||||||
} else {
|
} else {
|
||||||
mode
|
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) {
|
if (!received && targetMode != previousMode) {
|
||||||
service.setANCMode(targetMode.ordinal + 1)
|
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
|
||||||
// onModeSelectedCallback() // REMOVE this call to keep dialog open
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update divider alphas based on the *new* mode
|
when (noiseControlMode.value) {
|
||||||
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
|
|
||||||
NoiseControlMode.NOISE_CANCELLATION -> {
|
NoiseControlMode.NOISE_CANCELLATION -> {
|
||||||
d1a.floatValue = 1f
|
d1a.floatValue = 1f
|
||||||
d2a.floatValue = 1f
|
d2a.floatValue = 1f
|
||||||
@@ -447,5 +440,5 @@ fun NoiseControlSettings(
|
|||||||
@Preview()
|
@Preview()
|
||||||
@Composable
|
@Composable
|
||||||
fun NoiseControlSettingsPreview() {
|
fun NoiseControlSettingsPreview() {
|
||||||
NoiseControlSettings(AirPodsService()) {}
|
NoiseControlSettings(AirPodsService())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
|
||||||
var personalizedVolumeEnabled by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
sharedPreferences.getBoolean("personalized_volume", true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updatePersonalizedVolume(enabled: Boolean) {
|
|
||||||
personalizedVolumeEnabled = enabled
|
|
||||||
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
|
|
||||||
service.setPVEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
shape = RoundedCornerShape(14.dp),
|
|
||||||
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTapGestures(
|
|
||||||
onPress = {
|
|
||||||
isPressed.value = true
|
|
||||||
tryAwaitRelease()
|
|
||||||
isPressed.value = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = remember { MutableInteractionSource() }
|
|
||||||
) {
|
|
||||||
updatePersonalizedVolume(!personalizedVolumeEnabled)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Personalized Volume",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Adjusts the volume of media in response to your environment.",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
lineHeight = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = personalizedVolumeEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
updatePersonalizedVolume(it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun PersonalizedVolumeSwitchPreview() {
|
|
||||||
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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
|
@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 {
|
var singleANCEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("single_anc", true)
|
singleANCEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSingleEnabled(enabled: Boolean) {
|
fun updateSingleEnabled(enabled: Boolean) {
|
||||||
singleANCEnabled = enabled
|
singleANCEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setNoiseCancellationWithOnePod(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun SinglePodANCSwitchPreview() {
|
fun SinglePodANCSwitchPreview() {
|
||||||
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
SinglePodANCSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
|
|||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import me.kavishdevar.librepods.R
|
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
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
|
fun ToneVolumeSlider() {
|
||||||
val sliderValue = remember { mutableFloatStateOf(0f) }
|
val service = ServiceManager.getService()!!
|
||||||
LaunchedEffect(sliderValue) {
|
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
|
||||||
if (sharedPreferences.contains("tone_volume")) {
|
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
|
||||||
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
|
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||||
}
|
val sliderValue = remember { mutableFloatStateOf(
|
||||||
}
|
sliderValueFromAACP?.toFloat() ?: -1f
|
||||||
LaunchedEffect(sliderValue.floatValue) {
|
) }
|
||||||
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
|
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
|
||||||
}
|
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -99,7 +99,12 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
valueRange = 0f..100f,
|
valueRange = 0f..100f,
|
||||||
onValueChangeFinished = {
|
onValueChangeFinished = {
|
||||||
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
|
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
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun ToneVolumeSliderPreview() {
|
fun ToneVolumeSliderPreview() {
|
||||||
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
ToneVolumeSlider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
/*
|
/*
|
||||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
*
|
*
|
||||||
* Copyright (C) 2025 LibrePods contributors
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
* by the Free Software Foundation, either version 3 of the License.
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.composables
|
package me.kavishdevar.librepods.composables
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
@@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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
|
@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 {
|
var volumeControlEnabled by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
sharedPreferences.getBoolean("volume_control", true)
|
volumeControlEnabledValue == 1.toByte()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fun updateVolumeControlEnabled(enabled: Boolean) {
|
fun updateVolumeControlEnabled(enabled: Boolean) {
|
||||||
volumeControlEnabled = enabled
|
volumeControlEnabled = enabled
|
||||||
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
|
service.aacpManager.sendControlCommand(
|
||||||
service.setVolumeControl(enabled)
|
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
|
||||||
|
enabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun VolumeControlSwitchPreview() {
|
fun VolumeControlSwitchPreview() {
|
||||||
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
|
VolumeControlSwitch()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.receivers
|
package me.kavishdevar.librepods.receivers
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
|
|
||||||
class BootReceiver: BroadcastReceiver() {
|
class BootReceiver: BroadcastReceiver() {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -84,7 +85,6 @@ import androidx.navigation.NavController
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dev.chrisbanes.haze.HazeEffectScope
|
import dev.chrisbanes.haze.HazeEffectScope
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.haze
|
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
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.composables.PressAndHoldSettings
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
@@ -355,7 +357,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
PressAndHoldSettings(navController = navController)
|
PressAndHoldSettings(navController = navController)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AudioSettings(service = service, sharedPreferences = sharedPreferences)
|
AudioSettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
@@ -363,20 +365,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
service = service,
|
service = service,
|
||||||
functionName = "setEarDetection",
|
functionName = "setEarDetection",
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
true
|
default = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
IndependentToggle(
|
IndependentToggle(
|
||||||
name = "Off Listening Mode",
|
name = "Off Listening Mode",
|
||||||
service = service,
|
service = service,
|
||||||
functionName = "setOffListeningMode",
|
|
||||||
sharedPreferences = sharedPreferences,
|
sharedPreferences = sharedPreferences,
|
||||||
false
|
default = false,
|
||||||
|
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
|
AccessibilitySettings()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NavigationButton("debug", "Debug", navController)
|
NavigationButton("debug", "Debug", navController)
|
||||||
|
|||||||
@@ -119,7 +119,29 @@ fun AppSettingsScreen(navController: NavController) {
|
|||||||
var disconnectWhenNotWearing by remember {
|
var disconnectWhenNotWearing by remember {
|
||||||
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
|
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) }
|
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
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(
|
||||||
text = "Advanced Options".uppercase(),
|
text = "Advanced Options".uppercase(),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalHazeMaterialsApi::class)
|
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
data class PacketInfo(
|
data class PacketInfo(
|
||||||
val type: String,
|
val type: String,
|
||||||
@@ -616,7 +617,12 @@ fun DebugScreen(navController: NavController) {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (packet.value.text.isNotBlank()) {
|
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("")
|
packet.value = TextFieldValue("")
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -115,6 +117,7 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.composables.IndependentToggle
|
import me.kavishdevar.librepods.composables.IndependentToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -47,7 +49,6 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -69,6 +70,9 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.experimental.and
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@Composable()
|
@Composable()
|
||||||
fun RightDivider() {
|
fun RightDivider() {
|
||||||
@@ -83,15 +87,23 @@ fun RightDivider() {
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(navController: NavController, name: String) {
|
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 isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
CenterAlignedTopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
@@ -115,7 +127,7 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
modifier = Modifier.scale(1.5f)
|
modifier = Modifier.scale(1.5f)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
sharedPreferences.getString("name", "AirPods")!!,
|
deviceName?: "AirPods Pro",
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
@@ -159,14 +171,29 @@ fun LongPress(navController: NavController, name: String) {
|
|||||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||||
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
|
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()
|
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()
|
RightDivider()
|
||||||
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
|
LongPressElement(
|
||||||
|
name = "Adaptive",
|
||||||
|
resourceId = R.drawable.adaptive)
|
||||||
RightDivider()
|
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(
|
Text(
|
||||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
"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
|
@Composable
|
||||||
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
|
||||||
val sharedPreferences =
|
val bit = when (name) {
|
||||||
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
"Off" -> 0x01
|
||||||
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
|
"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 darkMode = isSystemInDarkTheme()
|
||||||
val textColor = if (darkMode) Color.White else Color.Black
|
val textColor = if (darkMode) Color.White else Color.Black
|
||||||
val desc = when (name) {
|
val desc = when (name) {
|
||||||
@@ -194,30 +241,72 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
"Adaptive" -> "Dynamically adjust external noise"
|
"Adaptive" -> "Dynamically adjust external noise"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
fun valueChanged(value: Boolean = !checked.value) {
|
|
||||||
val originalLongPressArray = booleanArrayOf(
|
fun countEnabledModes(byteValue: Int): Int {
|
||||||
sharedPreferences.getBoolean("long_press_off", false),
|
var count = 0
|
||||||
sharedPreferences.getBoolean("long_press_nc", false),
|
if ((byteValue and 0x01) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_transparency", false),
|
if ((byteValue and 0x02) != 0) count++
|
||||||
sharedPreferences.getBoolean("long_press_adaptive", false)
|
if ((byteValue and 0x04) != 0) count++
|
||||||
)
|
if ((byteValue and 0x08) != 0) count++
|
||||||
if (!value && originalLongPressArray.count { it } <= 2) {
|
|
||||||
return
|
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
|
||||||
}
|
return count
|
||||||
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 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 {
|
val shape = when {
|
||||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||||
@@ -238,8 +327,8 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
|
|||||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||||
tryAwaitRelease()
|
tryAwaitRelease()
|
||||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
valueChanged()
|
||||||
},
|
},
|
||||||
onTap = { valueChanged() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.screens
|
package me.kavishdevar.librepods.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -66,6 +68,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun RenameScreenPreview() {
|
fun RenameScreenPreview() {
|
||||||
RenameScreen(navController = NavController(LocalContext.current))
|
RenameScreen(navController = NavController(LocalContext.current))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.services
|
package me.kavishdevar.librepods.services
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
@@ -31,12 +33,12 @@ import android.service.quicksettings.Tile
|
|||||||
import android.service.quicksettings.TileService
|
import android.service.quicksettings.TileService
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import me.kavishdevar.librepods.MainActivity
|
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class AirPodsQSService : TileService() {
|
class AirPodsQSService : TileService() {
|
||||||
@@ -171,10 +173,11 @@ class AirPodsQSService : TileService() {
|
|||||||
)
|
)
|
||||||
startActivityAndCollapse(pendingIntent)
|
startActivityAndCollapse(pendingIntent)
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
}
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||||
startActivityAndCollapse(intent)
|
startActivityAndCollapse(intent)
|
||||||
}
|
}
|
||||||
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
|
||||||
@@ -191,14 +194,17 @@ class AirPodsQSService : TileService() {
|
|||||||
}
|
}
|
||||||
val nextMode = getNextAncMode()
|
val nextMode = getNextAncMode()
|
||||||
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
|
||||||
service.setANCMode(nextMode)
|
service.aacpManager.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
nextMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateTile() {
|
private fun updateTile() {
|
||||||
val tile = qsTile ?: return
|
val tile = qsTile ?: return
|
||||||
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
|
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
|
||||||
|
|
||||||
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
|
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
|
||||||
|
|
||||||
if (isAirPodsConnected) {
|
if (isAirPodsConnected) {
|
||||||
tile.state = Tile.STATE_ACTIVE
|
tile.state = Tile.STATE_ACTIVE
|
||||||
@@ -262,42 +268,9 @@ class AirPodsQSService : TileService() {
|
|||||||
else -> R.drawable.airpods
|
else -> R.drawable.airpods
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
|
||||||
override fun onTileAdded() {
|
override fun onTileAdded() {
|
||||||
super.onTileAdded()
|
super.onTileAdded()
|
||||||
Log.d("AirPodsQSService", "Tile added")
|
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||||
|
* This class is responsible for handling the L2CAP socket management,
|
||||||
|
* constructing and parsing packets for communication with Apple accessories.
|
||||||
|
*/
|
||||||
|
class AACPManager {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AACPManager"
|
||||||
|
|
||||||
|
object Opcodes {
|
||||||
|
const val SET_FEATURE_FLAGS: Byte = 0x4d
|
||||||
|
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
|
||||||
|
const val BATTERY_INFO: Byte = 0x04
|
||||||
|
const val CONTROL_COMMAND: Byte = 0x09
|
||||||
|
const val EAR_DETECTION: Byte = 0x06
|
||||||
|
const val CONVERSATION_AWARENESS: Byte = 0x4b
|
||||||
|
const val DEVICE_METADATA: Byte = 0x1d
|
||||||
|
const val RENAME: Byte = 0x1E
|
||||||
|
const val HEADTRACKING: Byte = 0x17
|
||||||
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
|
|
||||||
|
data class ControlCommandStatus(
|
||||||
|
val identifier: ControlCommandIdentifiers,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommandStatus
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.hashCode()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Suppress("unused")
|
||||||
|
enum class ControlCommandIdentifiers(val value: Byte) {
|
||||||
|
MIC_MODE(0x01),
|
||||||
|
BUTTON_SEND_MODE(0x05),
|
||||||
|
VOICE_TRIGGER(0x12),
|
||||||
|
SINGLE_CLICK_MODE(0x14),
|
||||||
|
DOUBLE_CLICK_MODE(0x15),
|
||||||
|
CLICK_HOLD_MODE(0x16),
|
||||||
|
DOUBLE_CLICK_INTERVAL(0x17),
|
||||||
|
CLICK_HOLD_INTERVAL(0x18),
|
||||||
|
LISTENING_MODE_CONFIGS(0x1A),
|
||||||
|
ONE_BUD_ANC_MODE(0x1B),
|
||||||
|
CROWN_ROTATION_DIRECTION(0x1C),
|
||||||
|
LISTENING_MODE(0x0D),
|
||||||
|
AUTO_ANSWER_MODE(0x1E),
|
||||||
|
CHIME_VOLUME(0x1F),
|
||||||
|
VOLUME_SWIPE_INTERVAL(0x23),
|
||||||
|
CALL_MANAGEMENT_CONFIG(0x24),
|
||||||
|
VOLUME_SWIPE_MODE(0x25),
|
||||||
|
ADAPTIVE_VOLUME_CONFIG(0x26),
|
||||||
|
SOFTWARE_MUTE_CONFIG(0x27),
|
||||||
|
CONVERSATION_DETECT_CONFIG(0x28),
|
||||||
|
SSL(0x29),
|
||||||
|
HEARING_AID(0x2C),
|
||||||
|
AUTO_ANC_STRENGTH(0x2E),
|
||||||
|
HPS_GAIN_SWIPE(0x2F),
|
||||||
|
HRM_STATE(0x30),
|
||||||
|
IN_CASE_TONE_CONFIG(0x31),
|
||||||
|
SIRI_MULTITONE_CONFIG(0x32),
|
||||||
|
HEARING_ASSIST_CONFIG(0x33),
|
||||||
|
ALLOW_OFF_OPTION(0x34);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||||
|
entries.find { it.value == byte }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ProximityKeyType(val value: Byte) {
|
||||||
|
IRK(0x01),
|
||||||
|
ENC_KEY(0x04);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): ProximityKeyType =
|
||||||
|
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||||
|
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||||
|
|
||||||
|
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||||
|
return controlCommandStatusList.find { it.identifier == identifier }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
|
||||||
|
val existingStatus = getControlCommandStatus(identifier)
|
||||||
|
if (existingStatus == value) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
if (existingStatus != null) {
|
||||||
|
controlCommandStatusList.remove(existingStatus)
|
||||||
|
}
|
||||||
|
controlCommandListeners[identifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
|
||||||
|
}
|
||||||
|
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PacketCallback {
|
||||||
|
fun onBatteryInfoReceived(batteryInfo: ByteArray)
|
||||||
|
fun onEarDetectionReceived(earDetection: ByteArray)
|
||||||
|
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
|
||||||
|
fun onControlCommandReceived(controlCommand: ByteArray)
|
||||||
|
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
|
||||||
|
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||||
|
fun onUnknownPacketReceived(packet: ByteArray)
|
||||||
|
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ControlCommandListener {
|
||||||
|
fun onControlCommandReceived(controlCommand: ControlCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
|
||||||
|
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var callback: PacketCallback? = null
|
||||||
|
|
||||||
|
fun setPacketCallback(callback: PacketCallback) {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createDataPacket(data: ByteArray): ByteArray {
|
||||||
|
return HEADER_BYTES + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
|
||||||
|
val payload = ByteArray(7)
|
||||||
|
|
||||||
|
System.arraycopy(opcode, 0, payload, 0, 2)
|
||||||
|
payload[2] = identifier
|
||||||
|
|
||||||
|
val dataLength = minOf(data.size, 4)
|
||||||
|
System.arraycopy(data, 0, payload, 3, dataLength)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendDataPacket(data: ByteArray): Boolean {
|
||||||
|
return sendPacket(createDataPacket(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, value)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
value
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
|
||||||
|
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
|
||||||
|
byteArrayOf(value.toByte())
|
||||||
|
)
|
||||||
|
return sendDataPacket(controlPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
|
||||||
|
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
|
||||||
|
}
|
||||||
|
val keyCount = data[6].toInt()
|
||||||
|
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
|
||||||
|
var offset = 7
|
||||||
|
for (i in 0 until keyCount) {
|
||||||
|
Log.d(TAG, "Parsing Proximity Key $i")
|
||||||
|
if (offset + 3 >= data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val keyType = data[offset]
|
||||||
|
val keyLength = data[offset + 2].toInt()
|
||||||
|
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
|
||||||
|
offset += 4
|
||||||
|
if (offset + keyLength > data.size) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
|
||||||
|
}
|
||||||
|
val key = ByteArray(keyLength)
|
||||||
|
System.arraycopy(data, offset, key, 0, keyLength)
|
||||||
|
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||||
|
offset += keyLength
|
||||||
|
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRequestProximityKeys(type: Byte): Boolean {
|
||||||
|
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
|
||||||
|
return sendDataPacket(createRequestProximityKeysPacket(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
|
||||||
|
val data = byteArrayOf(type, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun receivePacket(packet: ByteArray) {
|
||||||
|
if (!packet.toHexString().startsWith("04000400")) {
|
||||||
|
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (packet.size < 6) {
|
||||||
|
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val opcode = packet[4]
|
||||||
|
|
||||||
|
when (opcode) {
|
||||||
|
Opcodes.BATTERY_INFO -> {
|
||||||
|
callback?.onBatteryInfoReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONTROL_COMMAND -> {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
Log.d(TAG, "Control command list is now: ${
|
||||||
|
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
|
||||||
|
}")
|
||||||
|
|
||||||
|
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
|
||||||
|
if (controlCommandIdentifier != null) {
|
||||||
|
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
|
||||||
|
listener.onControlCommandReceived(controlCommand)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
callback?.onControlCommandReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.EAR_DETECTION -> {
|
||||||
|
callback?.onEarDetectionReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.CONVERSATION_AWARENESS -> {
|
||||||
|
callback?.onConversationAwarenessReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.DEVICE_METADATA -> {
|
||||||
|
callback?.onDeviceMetadataReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.HEADTRACKING -> {
|
||||||
|
if (packet.size < 70) {
|
||||||
|
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback?.onHeadTrackingReceived(packet)
|
||||||
|
}
|
||||||
|
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||||
|
callback?.onProximityKeysReceived(packet)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
callback?.onUnknownPacketReceived(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendNotificationRequest(): Boolean {
|
||||||
|
return sendDataPacket(createRequestNotificationPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRequestNotificationPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendSetFeatureFlagsPacket(): Boolean {
|
||||||
|
return sendDataPacket(createSetFeatureFlagsPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSetFeatureFlagsPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
|
||||||
|
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createHandshakePacket(): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
0x00, 0x00, 0x04, 0x00,
|
||||||
|
0x01, 0x00, 0x02, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStartHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStartHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStartHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendStopHeadTracking(): Boolean {
|
||||||
|
return sendDataPacket(createStopHeadTrackingPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createStopHeadTrackingPacket(): ByteArray {
|
||||||
|
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||||
|
val data = byteArrayOf(
|
||||||
|
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||||
|
)
|
||||||
|
return opcode + data
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendRename(name: String): Boolean {
|
||||||
|
return sendDataPacket(createRenamePacket(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createRenamePacket(name: String): ByteArray {
|
||||||
|
val nameBytes = name.toByteArray()
|
||||||
|
val size = nameBytes.size
|
||||||
|
val packet = ByteArray(5 + size)
|
||||||
|
packet[0] = Opcodes.RENAME
|
||||||
|
packet[1] = 0x00
|
||||||
|
packet[2] = size.toByte()
|
||||||
|
packet[3] = 0x00
|
||||||
|
System.arraycopy(nameBytes, 0, packet, 4, size)
|
||||||
|
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class ControlCommand(
|
||||||
|
val identifier: Byte,
|
||||||
|
val value: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ControlCommand
|
||||||
|
|
||||||
|
if (identifier != other.identifier) return false
|
||||||
|
if (!value.contentEquals(other.value)) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result: Int = identifier.toInt()
|
||||||
|
result = 31 * result + value.contentHashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByteArray(data: ByteArray): ControlCommand {
|
||||||
|
if (data.size < 4) {
|
||||||
|
throw IllegalArgumentException("Data array too short to parse ControlCommand")
|
||||||
|
}
|
||||||
|
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
|
||||||
|
val newData = ByteArray(data.size - 4)
|
||||||
|
System.arraycopy(data, 4, newData, 0, data.size - 4)
|
||||||
|
return fromByteArray(newData)
|
||||||
|
}
|
||||||
|
if (data[0] != Opcodes.CONTROL_COMMAND) {
|
||||||
|
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
|
||||||
|
}
|
||||||
|
val identifier = data[2]
|
||||||
|
|
||||||
|
val value = ByteArray(4)
|
||||||
|
System.arraycopy(data, 3, value, 0, 4)
|
||||||
|
|
||||||
|
// drop trailing zeroes in the array, and return the bytearray of the reduced array
|
||||||
|
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||||
|
return ControlCommand(identifier, trimmedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
|
fun sendPacket(packet: ByteArray): Boolean {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
|
||||||
|
if (packet[4] == Opcodes.CONTROL_COMMAND) {
|
||||||
|
val controlCommand = ControlCommand.fromByteArray(packet)
|
||||||
|
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
|
||||||
|
setControlCommandStatusValue(
|
||||||
|
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
|
||||||
|
controlCommand.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val socket = BluetoothConnectionManager.getCurrentSocket()
|
||||||
|
if (socket?.isConnected == true) {
|
||||||
|
socket.outputStream?.write(packet)
|
||||||
|
socket.outputStream?.flush()
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error sending packet: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.le.BluetoothLeScanner
|
||||||
|
import android.bluetooth.le.ScanCallback
|
||||||
|
import android.bluetooth.le.ScanFilter
|
||||||
|
import android.bluetooth.le.ScanResult
|
||||||
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class BLEManager(private val context: Context) {
|
||||||
|
|
||||||
|
data class AirPodsStatus(
|
||||||
|
val address: String,
|
||||||
|
val lastSeen: Long = System.currentTimeMillis(),
|
||||||
|
val paired: Boolean = false,
|
||||||
|
val model: String = "Unknown",
|
||||||
|
val leftBattery: Int? = null,
|
||||||
|
val rightBattery: Int? = null,
|
||||||
|
val caseBattery: Int? = null,
|
||||||
|
val isLeftInEar: Boolean = false,
|
||||||
|
val isRightInEar: Boolean = false,
|
||||||
|
val isLeftCharging: Boolean = false,
|
||||||
|
val isRightCharging: Boolean = false,
|
||||||
|
val isCaseCharging: Boolean = false,
|
||||||
|
val lidOpen: Boolean = false,
|
||||||
|
val color: String = "Unknown",
|
||||||
|
val connectionState: String = "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getMostRecentStatus(): AirPodsStatus? {
|
||||||
|
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AirPodsStatusListener {
|
||||||
|
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
|
||||||
|
fun onBroadcastFromNewAddress(device: AirPodsStatus)
|
||||||
|
fun onLidStateChanged(lidOpen: Boolean)
|
||||||
|
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
|
||||||
|
fun onBatteryChanged(device: AirPodsStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mBluetoothLeScanner: BluetoothLeScanner? = null
|
||||||
|
private var mScanCallback: ScanCallback? = null
|
||||||
|
private var airPodsStatusListener: AirPodsStatusListener? = null
|
||||||
|
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
|
||||||
|
private val verifiedAddresses = mutableSetOf<String>()
|
||||||
|
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||||
|
private var currentGlobalLidState: Boolean? = null
|
||||||
|
private var lastBroadcastTime: Long = 0
|
||||||
|
private val processedAddresses = mutableSetOf<String>()
|
||||||
|
private val modelNames = mapOf(
|
||||||
|
0x0E20 to "AirPods Pro",
|
||||||
|
0x1420 to "AirPods Pro 2",
|
||||||
|
0x2420 to "AirPods Pro 2 (USB-C)",
|
||||||
|
0x0220 to "AirPods 1",
|
||||||
|
0x0F20 to "AirPods 2",
|
||||||
|
0x1320 to "AirPods 3",
|
||||||
|
0x1920 to "AirPods 4",
|
||||||
|
0x1B20 to "AirPods 4 (ANC)",
|
||||||
|
0x0A20 to "AirPods Max",
|
||||||
|
0x1F20 to "AirPods Max (USB-C)"
|
||||||
|
)
|
||||||
|
|
||||||
|
val colorNames = mapOf(
|
||||||
|
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
|
||||||
|
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
|
||||||
|
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
|
||||||
|
0x0B to "Light Blue", 0x0C to "Yellow"
|
||||||
|
)
|
||||||
|
|
||||||
|
val connStates = mapOf(
|
||||||
|
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
|
||||||
|
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val cleanupHandler = Handler(Looper.getMainLooper())
|
||||||
|
private val cleanupRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
cleanupStaleDevices()
|
||||||
|
checkLidStateTimeout()
|
||||||
|
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
|
||||||
|
airPodsStatusListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startScanning() {
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Starting BLE scanner")
|
||||||
|
|
||||||
|
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val btAdapter = btManager.adapter
|
||||||
|
|
||||||
|
if (btAdapter == null) {
|
||||||
|
Log.d(TAG, "No Bluetooth adapter available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btAdapter.isEnabled) {
|
||||||
|
Log.d(TAG, "Bluetooth is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
|
||||||
|
|
||||||
|
val scanSettings = ScanSettings.Builder()
|
||||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||||
|
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||||
|
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||||
|
.setReportDelay(500L)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manufacturerData = ByteArray(27)
|
||||||
|
val manufacturerDataMask = ByteArray(27)
|
||||||
|
|
||||||
|
manufacturerData[0] = 7
|
||||||
|
manufacturerData[1] = 25
|
||||||
|
|
||||||
|
manufacturerDataMask[0] = -1
|
||||||
|
manufacturerDataMask[1] = -1
|
||||||
|
|
||||||
|
val scanFilter = ScanFilter.Builder()
|
||||||
|
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mScanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatchScanResults(results: List<ScanResult>) {
|
||||||
|
processedAddresses.clear()
|
||||||
|
for (result in results) {
|
||||||
|
processScanResult(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
Log.e(TAG, "BLE scan failed with error code: $errorCode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
|
||||||
|
Log.d(TAG, "BLE scanner started successfully")
|
||||||
|
|
||||||
|
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error starting BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun stopScanning() {
|
||||||
|
try {
|
||||||
|
if (mBluetoothLeScanner != null && mScanCallback != null) {
|
||||||
|
Log.d(TAG, "Stopping BLE scanner")
|
||||||
|
mBluetoothLeScanner?.stopScan(mScanCallback)
|
||||||
|
mScanCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupHandler.removeCallbacks(cleanupRunnable)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error stopping BLE scanner", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processScanResult(result: ScanResult) {
|
||||||
|
try {
|
||||||
|
val scanRecord = result.scanRecord ?: return
|
||||||
|
val address = result.device.address
|
||||||
|
|
||||||
|
if (processedAddresses.contains(address)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||||
|
if (manufacturerData.size <= 20) return
|
||||||
|
|
||||||
|
if (!verifiedAddresses.contains(address)) {
|
||||||
|
val irk = getIrkFromPreferences()
|
||||||
|
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
verifiedAddresses.add(address)
|
||||||
|
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAddresses.add(address)
|
||||||
|
lastBroadcastTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val parsedStatus = parseProximityMessage(address, manufacturerData)
|
||||||
|
val previousStatus = deviceStatusMap[address]
|
||||||
|
|
||||||
|
deviceStatusMap[address] = parsedStatus
|
||||||
|
|
||||||
|
airPodsStatusListener?.let { listener ->
|
||||||
|
if (previousStatus == null) {
|
||||||
|
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||||
|
Log.d(TAG, "New AirPods device detected: $address")
|
||||||
|
|
||||||
|
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parsedStatus != previousStatus) {
|
||||||
|
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||||
|
val previousGlobalState = currentGlobalLidState
|
||||||
|
currentGlobalLidState = parsedStatus.lidOpen
|
||||||
|
|
||||||
|
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||||
|
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||||
|
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||||
|
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||||
|
listener.onEarStateChanged(
|
||||||
|
parsedStatus,
|
||||||
|
parsedStatus.isLeftInEar,
|
||||||
|
parsedStatus.isRightInEar
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||||
|
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||||
|
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||||
|
listener.onBatteryChanged(parsedStatus)
|
||||||
|
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "Error processing scan result", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanupStaleDevices() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||||
|
|
||||||
|
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
||||||
|
|
||||||
|
for (device in staleDevices) {
|
||||||
|
deviceStatusMap.remove(device.key)
|
||||||
|
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLidStateTimeout() {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
|
||||||
|
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
|
||||||
|
currentGlobalLidState = false
|
||||||
|
airPodsStatusListener?.onLidStateChanged(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
private fun getIrkFromPreferences(): ByteArray? {
|
||||||
|
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||||
|
return if (irkBase64 != null) {
|
||||||
|
try {
|
||||||
|
Base64.decode(irkBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode IRK", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
|
||||||
|
val paired = data[2].toInt() == 1
|
||||||
|
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
|
||||||
|
val model = modelNames[modelId] ?: "Unknown ($modelId)"
|
||||||
|
|
||||||
|
val status = data[5].toInt() and 0xFF
|
||||||
|
val podsBattery = data[6].toInt() and 0xFF
|
||||||
|
val flagsCase = data[7].toInt() and 0xFF
|
||||||
|
val lid = data[8].toInt() and 0xFF
|
||||||
|
val color = colorNames[data[9].toInt()] ?: "Unknown"
|
||||||
|
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
|
||||||
|
|
||||||
|
val primaryLeft = ((status shr 5) and 0x01) == 1
|
||||||
|
val thisInCase = ((status shr 6) and 0x01) == 1
|
||||||
|
val xorFactor = primaryLeft xor thisInCase
|
||||||
|
|
||||||
|
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
|
||||||
|
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
|
||||||
|
|
||||||
|
val leftBatteryNibble = if (xorFactor) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
|
||||||
|
val rightBatteryNibble = if (xorFactor) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
|
||||||
|
|
||||||
|
val caseBattery = (flagsCase shr 4) and 0x0F
|
||||||
|
val flags = flagsCase and 0x0F
|
||||||
|
|
||||||
|
val isRightCharging = if (xorFactor) (flags and 0x02) != 0 else (flags and 0x01) != 0
|
||||||
|
val isLeftCharging = if (xorFactor) (flags and 0x01) != 0 else (flags and 0x02) != 0
|
||||||
|
val isCaseCharging = (flags and 0x04) != 0
|
||||||
|
|
||||||
|
val lidOpen = ((lid shr 3) and 0x01) == 0
|
||||||
|
|
||||||
|
fun decodeBattery(n: Int): Int? = when (n) {
|
||||||
|
in 0x0..0x9 -> n * 10
|
||||||
|
in 0xA..0xE -> 100
|
||||||
|
0xF -> null
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return AirPodsStatus(
|
||||||
|
address = address,
|
||||||
|
lastSeen = System.currentTimeMillis(),
|
||||||
|
paired = paired,
|
||||||
|
model = model,
|
||||||
|
leftBattery = decodeBattery(leftBatteryNibble),
|
||||||
|
rightBattery = decodeBattery(rightBatteryNibble),
|
||||||
|
caseBattery = decodeBattery(caseBattery),
|
||||||
|
isLeftInEar = isLeftInEar,
|
||||||
|
isRightInEar = isRightInEar,
|
||||||
|
isLeftCharging = isLeftCharging,
|
||||||
|
isRightCharging = isRightCharging,
|
||||||
|
isCaseCharging = isCaseCharging,
|
||||||
|
lidOpen = lidOpen,
|
||||||
|
color = color,
|
||||||
|
connectionState = conn
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
override fun onDeviceStatusChanged(
|
||||||
|
device: BLEManager.AirPodsStatus,
|
||||||
|
previousStatus: BLEManager.AirPodsStatus?
|
||||||
|
) {
|
||||||
|
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||||
|
Log.d("AirPodsBLEService", "Checking if audio should be connected")
|
||||||
|
ServiceManager.getService()?.manuallyCheckForAudioSource()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("AirPodsBLEService", "Device status changed, inEar: ${device.isLeftInEar}, ${device.isRightInEar}")
|
||||||
|
|
||||||
|
if (previousStatus != null && device.connectionState != previousStatus.connectionState) {
|
||||||
|
Log.d("AirPodsBLEService", "Connection state changed from ${previousStatus.connectionState} to ${device.connectionState}")
|
||||||
|
|
||||||
|
if (ServiceManager.getService()?.shouldTakeOverBasedOnAirPodsState(device.connectionState) == true) {
|
||||||
|
Log.d("AirPodsBLEService", "Taking over based on AirPods state: ${device.connectionState}")
|
||||||
|
|
||||||
|
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(context.getSharedPreferences(
|
||||||
|
"settings", Context.MODE_PRIVATE).getString("mac_address", "") ?: "")
|
||||||
|
|
||||||
|
ServiceManager.getService()?.connectToSocket(bluetoothDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) {
|
||||||
|
// Implement this method if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLidStateChanged(lidOpen: Boolean) {
|
||||||
|
// Implement this method if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEarStateChanged(device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) {
|
||||||
|
// Implement this method if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||||
|
// Implement this method if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AirPodsBLE"
|
||||||
|
private const val CLEANUP_INTERVAL_MS = 30000L
|
||||||
|
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
|
||||||
|
private const val LID_CLOSE_TIMEOUT_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object BluetoothConnectionManager {
|
||||||
|
private const val TAG = "BluetoothConnectionManager"
|
||||||
|
|
||||||
|
private var currentSocket: BluetoothSocket? = null
|
||||||
|
private var currentDevice: BluetoothDevice? = null
|
||||||
|
|
||||||
|
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
||||||
|
currentSocket = socket
|
||||||
|
currentDevice = device
|
||||||
|
Log.d(TAG, "Current connection set to device: ${device.address}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentSocket(): BluetoothSocket? {
|
||||||
|
return currentSocket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* LibrePods - AirPods liberated from Apple's ecosystem
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 LibrePods Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utilities for Bluetooth cryptography operations, particularly for
|
||||||
|
* verifying Resolvable Private Addresses (RPA) used by AirPods.
|
||||||
|
*/
|
||||||
|
object BluetoothCryptography {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
|
||||||
|
*
|
||||||
|
* @param addr The Bluetooth address to verify
|
||||||
|
* @param irk The Identity Resolving Key to use for verification
|
||||||
|
* @return true if the address is verified as an RPA matching the IRK
|
||||||
|
*/
|
||||||
|
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
|
||||||
|
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
|
||||||
|
val prand = rpa.copyOfRange(3, 6)
|
||||||
|
val hash = rpa.copyOfRange(0, 3)
|
||||||
|
val computedHash = ah(irk, prand)
|
||||||
|
return hash.contentEquals(computedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs E function (AES-128) as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param key The key for encryption
|
||||||
|
* @param data The data to encrypt
|
||||||
|
* @return The encrypted data
|
||||||
|
*/
|
||||||
|
fun e(key: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val swappedKey = key.reversedArray()
|
||||||
|
val swappedData = data.reversedArray()
|
||||||
|
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
|
||||||
|
val secretKey = SecretKeySpec(swappedKey, "AES")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
|
||||||
|
return cipher.doFinal(swappedData).reversedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the ah function as specified in Bluetooth Core Specification
|
||||||
|
*
|
||||||
|
* @param k The IRK key
|
||||||
|
* @param r The random part of the address
|
||||||
|
* @return The hash part of the address
|
||||||
|
*/
|
||||||
|
fun ah(k: ByteArray, r: ByteArray): ByteArray {
|
||||||
|
val rPadded = ByteArray(16)
|
||||||
|
r.copyInto(rPadded, 0, 0, 3)
|
||||||
|
val encrypted = e(k, rPadded)
|
||||||
|
return encrypted.copyOfRange(0, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ import kotlinx.coroutines.launch
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
enum class CrossDevicePackets(val packet: ByteArray) {
|
enum class CrossDevicePackets(val packet: ByteArray) {
|
||||||
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
|
||||||
@@ -87,7 +89,7 @@ object CrossDevice {
|
|||||||
private fun startServer() {
|
private fun startServer() {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
if (!bluetoothAdapter.isEnabled) return@launch
|
if (!bluetoothAdapter.isEnabled) return@launch
|
||||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||||
Log.d("CrossDevice", "Server started")
|
Log.d("CrossDevice", "Server started")
|
||||||
while (serverSocket != null) {
|
while (serverSocket != null) {
|
||||||
if (!bluetoothAdapter.isEnabled) {
|
if (!bluetoothAdapter.isEnabled) {
|
||||||
@@ -233,7 +235,7 @@ object CrossDevice {
|
|||||||
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
|
||||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||||
ServiceManager.getService()?.sendPacket(packetInHex)
|
// ServiceManager.getService()?.sendPacket(packetInHex)
|
||||||
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
|
||||||
batteryBytes = trimmedPacket
|
batteryBytes = trimmedPacket
|
||||||
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService
|
|||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -20,21 +23,18 @@ import kotlin.math.pow
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
class GestureDetector(
|
class GestureDetector(
|
||||||
private val airPodsService: AirPodsService,
|
private val airPodsService: AirPodsService
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "GestureDetector"
|
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 IMMEDIATE_FEEDBACK_THRESHOLD = 600
|
||||||
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
private const val DIRECTION_CHANGE_SENSITIVITY = 150
|
||||||
|
|
||||||
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
private const val FAST_MOVEMENT_THRESHOLD = 300.0
|
||||||
private const val MIN_REQUIRED_EXTREMES = 3
|
private const val MIN_REQUIRED_EXTREMES = 3
|
||||||
private const val MAX_REQUIRED_EXTREMES = 4
|
private const val MAX_REQUIRED_EXTREMES = 4
|
||||||
|
|
||||||
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
prevHorizontal = 0.0
|
prevHorizontal = 0.0
|
||||||
prevVertical = 0.0
|
prevVertical = 0.0
|
||||||
|
|
||||||
airPodsService.sendPacket(START_CMD)
|
airPodsService.aacpManager.sendStartHeadTracking()
|
||||||
|
|
||||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
while (isRunning) {
|
while (isRunning) {
|
||||||
@@ -117,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
Log.d(TAG, "Stopping gesture detection")
|
Log.d(TAG, "Stopping gesture detection")
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
if (!doNotStop) airPodsService.sendPacket(STOP_CMD)
|
if (!doNotStop) airPodsService.aacpManager.sendStopHeadTracking()
|
||||||
|
|
||||||
detectionJob?.cancel()
|
detectionJob?.cancel()
|
||||||
detectionJob = null
|
detectionJob = null
|
||||||
@@ -187,7 +187,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun detectPeaksAndTroughs() {
|
private fun detectPeaksAndTroughs() {
|
||||||
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ package me.kavishdevar.librepods.utils
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioDeviceInfo
|
|
||||||
import android.media.AudioFocusRequest
|
|
||||||
import android.media.AudioManager
|
|
||||||
import android.media.SoundPool
|
import android.media.SoundPool
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
@@ -22,44 +19,6 @@ class GestureFeedback(private val context: Context) {
|
|||||||
|
|
||||||
private val soundsLoaded = AtomicBoolean(false)
|
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()
|
private val soundPool = SoundPool.Builder()
|
||||||
.setMaxStreams(3)
|
.setMaxStreams(3)
|
||||||
.setAudioAttributes(
|
.setAudioAttributes(
|
||||||
@@ -201,12 +160,4 @@ class GestureFeedback(private val context: Context) {
|
|||||||
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ object HeadTracking {
|
|||||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
||||||
if (!isCalibrated) return Orientation()
|
if (!isCalibrated) return Orientation()
|
||||||
|
|
||||||
// Add offset before normalizationval
|
|
||||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
||||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
||||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
||||||
|
|||||||
@@ -16,57 +16,200 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.animation.Animator
|
import android.animation.Animator
|
||||||
import android.animation.AnimatorListenerAdapter
|
import android.animation.AnimatorListenerAdapter
|
||||||
import android.animation.ObjectAnimator
|
import android.animation.ObjectAnimator
|
||||||
import android.animation.PropertyValuesHolder
|
import android.animation.PropertyValuesHolder
|
||||||
|
import android.animation.ValueAnimator
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log.e
|
import android.util.Log.e
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.VelocityTracker
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
import android.view.animation.AnticipateOvershootInterpolator
|
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.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.VideoView
|
import android.widget.VideoView
|
||||||
import androidx.core.content.ContextCompat.getString
|
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.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
enum class IslandType {
|
enum class IslandType {
|
||||||
CONNECTED,
|
CONNECTED,
|
||||||
TAKING_OVER,
|
TAKING_OVER,
|
||||||
MOVED_TO_REMOTE,
|
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
|
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||||
@SuppressLint("InflateParams")
|
@SuppressLint("InflateParams")
|
||||||
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
|
||||||
private var isClosing = false
|
private var isClosing = false
|
||||||
|
private var params: WindowManager.LayoutParams? = null
|
||||||
|
|
||||||
|
private var initialY = 0f
|
||||||
|
private var initialTouchY = 0f
|
||||||
|
private var lastTouchY = 0f
|
||||||
|
private var velocityTracker: VelocityTracker? = null
|
||||||
|
private var isBeingDragged = false
|
||||||
|
private var autoCloseHandler: Handler? = null
|
||||||
|
private var autoCloseRunnable: Runnable? = null
|
||||||
|
private var initialHeight = 0
|
||||||
|
private var screenHeight = 0
|
||||||
|
private var isDraggingDown = false
|
||||||
|
private var lastMoveTime = 0L
|
||||||
|
private var yMovement = 0f
|
||||||
|
private var dragDistance = 0f
|
||||||
|
|
||||||
|
private var initialConnectedTextY = 0f
|
||||||
|
private var initialDeviceTextY = 0f
|
||||||
|
private var initialBatteryViewY = 0f
|
||||||
|
private var initialVideoViewY = 0f
|
||||||
|
private var initialTextSeparation = 0f
|
||||||
|
|
||||||
|
private val containerView = FrameLayout(context)
|
||||||
|
|
||||||
|
private lateinit var springAnimation: SpringAnimation
|
||||||
|
private val flingAnimator = ValueAnimator()
|
||||||
|
|
||||||
|
private val batteryReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||||
|
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
|
||||||
|
updateBatteryDisplay(batteryList)
|
||||||
|
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||||
|
try {
|
||||||
|
context?.unregisterReceiver(this)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val isVisible: Boolean
|
val isVisible: Boolean
|
||||||
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
|
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||||
|
if (batteryList == null || batteryList.isEmpty()) return
|
||||||
|
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
val leftLevel = leftBattery?.level ?: 0
|
||||||
|
val rightLevel = rightBattery?.level ?: 0
|
||||||
|
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
|
||||||
|
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = when {
|
||||||
|
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
|
||||||
|
leftLevel > 0 -> leftLevel
|
||||||
|
rightLevel > 0 -> rightLevel
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
|
||||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||||
if (ServiceManager.getService()?.islandOpen == true) return
|
if (ServiceManager.getService()?.islandOpen == true) return
|
||||||
else ServiceManager.getService()?.islandOpen = true
|
else ServiceManager.getService()?.islandOpen = true
|
||||||
|
|
||||||
val displayMetrics = Resources.getSystem().displayMetrics
|
val displayMetrics = Resources.getSystem().displayMetrics
|
||||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||||
|
screenHeight = displayMetrics.heightPixels
|
||||||
|
|
||||||
val params = WindowManager.LayoutParams(
|
val batteryList = ServiceManager.getService()?.getBattery()
|
||||||
|
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||||
|
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||||
|
|
||||||
|
val displayBatteryLevel = if (batteryList != null) {
|
||||||
|
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||||
|
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||||
|
|
||||||
|
when {
|
||||||
|
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||||
|
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||||
|
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
|
||||||
|
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
|
||||||
|
batteryPercentage > 0 -> batteryPercentage
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
} else if (batteryPercentage > 0) {
|
||||||
|
batteryPercentage
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayBatteryLevel != null) {
|
||||||
|
batteryText.text = "$displayBatteryLevel%"
|
||||||
|
batteryProgressBar.progress = displayBatteryLevel
|
||||||
|
} else {
|
||||||
|
batteryText.text = "?"
|
||||||
|
batteryProgressBar.progress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
batteryProgressBar.isIndeterminate = false
|
||||||
|
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||||
|
|
||||||
|
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||||
|
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(batteryReceiver, batteryIntentFilter)
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||||
|
|
||||||
|
containerView.removeAllViews()
|
||||||
|
val containerParams = FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
containerView.addView(islandView, containerParams)
|
||||||
|
|
||||||
|
params = WindowManager.LayoutParams(
|
||||||
width,
|
width,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
@@ -77,12 +220,97 @@ class IslandWindow(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
islandView.visibility = View.VISIBLE
|
islandView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
|
containerView.visibility = View.VISIBLE
|
||||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
|
||||||
|
|
||||||
islandView.setOnClickListener {
|
containerView.setOnTouchListener { _, event ->
|
||||||
ServiceManager.getService()?.startMainActivity()
|
when (event.action) {
|
||||||
close()
|
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) {
|
when (type) {
|
||||||
@@ -95,16 +323,8 @@ class IslandWindow(context: Context) {
|
|||||||
IslandType.MOVED_TO_REMOTE -> {
|
IslandType.MOVED_TO_REMOTE -> {
|
||||||
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
|
||||||
}
|
}
|
||||||
// IslandType.CALL_GESTURE -> {
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_connected_text).text = "Incoming Call from $name"
|
|
||||||
// islandView.findViewById<TextView>(R.id.island_device_name).text = "Use Head Gestures to answer."
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
|
||||||
batteryProgressBar.progress = batteryPercentage
|
|
||||||
batteryProgressBar.isIndeterminate = false
|
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
|
||||||
videoView.setVideoURI(videoUri)
|
videoView.setVideoURI(videoUri)
|
||||||
@@ -113,19 +333,265 @@ class IslandWindow(context: Context) {
|
|||||||
videoView.start()
|
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 scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
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
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
Handler(Looper.getMainLooper()).postDelayed({
|
|
||||||
close()
|
resetAutoCloseTimer()
|
||||||
}, 4500)
|
}
|
||||||
|
|
||||||
|
private fun captureInitialPositions() {
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
connectedText.post {
|
||||||
|
initialConnectedTextY = connectedText.y
|
||||||
|
initialDeviceTextY = deviceText.y
|
||||||
|
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
|
||||||
|
|
||||||
|
if (batteryView != null) initialBatteryViewY = batteryView.y
|
||||||
|
initialVideoViewY = videoView.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
|
||||||
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
|
|
||||||
|
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
|
||||||
|
val newMinHeight = (initialHeight * stretchFactor).toInt()
|
||||||
|
mainLayout.minimumHeight = newMinHeight
|
||||||
|
|
||||||
|
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = textMarginIncrease
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val background = mainLayout.background
|
||||||
|
if (background is GradientDrawable) {
|
||||||
|
val cornerRadius = 56f
|
||||||
|
background.cornerRadius = cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
|
||||||
|
val containerParams = containerView.layoutParams
|
||||||
|
containerParams.height = screenHeight
|
||||||
|
containerView.layoutParams = containerParams
|
||||||
|
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetAutoCloseTimer() {
|
||||||
|
autoCloseHandler = Handler(Looper.getMainLooper())
|
||||||
|
autoCloseRunnable = Runnable { close() }
|
||||||
|
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun springBackWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
springAnimation.setStartVelocity(velocity)
|
||||||
|
|
||||||
|
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
|
||||||
|
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
|
||||||
|
springAnimation.spring = SpringForce(0f)
|
||||||
|
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||||
|
.setStiffness(dynamicStiffness)
|
||||||
|
|
||||||
|
resetStretchEffects(velocity)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
springAnimation.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetStretchEffects(velocity: Float) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
|
||||||
|
heightAnimator.duration = 300
|
||||||
|
heightAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
heightAnimator.addUpdateListener { animation ->
|
||||||
|
mainLayout.minimumHeight = animation.animatedValue as Int
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
|
||||||
|
textMarginAnimator.duration = 300
|
||||||
|
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||||
|
textMarginAnimator.addUpdateListener { animation ->
|
||||||
|
deviceTextParams.topMargin = animation.animatedValue as Int
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
}
|
||||||
|
|
||||||
|
heightAnimator.start()
|
||||||
|
textMarginAnimator.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateDismissWithInertia(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDistance = -screenHeight
|
||||||
|
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
|
||||||
|
val targetDistance = baseDistance * velocityFactor
|
||||||
|
|
||||||
|
val baseDuration = 400L
|
||||||
|
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
|
||||||
|
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
|
||||||
|
|
||||||
|
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
|
||||||
|
flingAnimator.duration = duration
|
||||||
|
flingAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
|
||||||
|
val progress = animation.animatedFraction
|
||||||
|
containerView.scaleX = 1f - (progress * 0.5f)
|
||||||
|
containerView.scaleY = 1f - (progress * 0.5f)
|
||||||
|
|
||||||
|
containerView.alpha = 1f - progress
|
||||||
|
}
|
||||||
|
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
|
||||||
|
flingAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateExpandWithStretch(velocity: Float) {
|
||||||
|
springAnimation.cancel()
|
||||||
|
flingAnimator.cancel()
|
||||||
|
|
||||||
|
val baseDuration = 600L
|
||||||
|
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
|
||||||
|
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
|
||||||
|
|
||||||
|
if (params != null) {
|
||||||
|
params!!.height = screenHeight
|
||||||
|
try {
|
||||||
|
windowManager.updateViewLayout(containerView, params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
|
||||||
|
containerAnimator.duration = expandDuration
|
||||||
|
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
|
||||||
|
containerAnimator.addUpdateListener { animation ->
|
||||||
|
containerView.translationY = animation.animatedValue as Float
|
||||||
|
}
|
||||||
|
|
||||||
|
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||||
|
stretchAnimator.duration = expandDuration
|
||||||
|
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
|
||||||
|
stretchAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
animateCustomStretch(progress, expandDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
|
||||||
|
normalizeAnimator.duration = 300
|
||||||
|
normalizeAnimator.startDelay = expandDuration - 150
|
||||||
|
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
|
||||||
|
normalizeAnimator.addUpdateListener { animation ->
|
||||||
|
val progress = animation.animatedValue as Float
|
||||||
|
containerView.alpha = progress
|
||||||
|
|
||||||
|
if (progress < 0.7f) {
|
||||||
|
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
|
ServiceManager.getService()?.startMainActivity()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
containerAnimator.start()
|
||||||
|
stretchAnimator.start()
|
||||||
|
normalizeAnimator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateCustomStretch(progress: Float, duration: Long) {
|
||||||
|
try {
|
||||||
|
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||||
|
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||||
|
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||||
|
|
||||||
|
val targetHeight = (screenHeight * 0.7f).toInt()
|
||||||
|
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
|
||||||
|
mainLayout.minimumHeight = currentHeight.toInt()
|
||||||
|
|
||||||
|
val mainLayoutParams = mainLayout.layoutParams
|
||||||
|
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
|
||||||
|
mainLayout.layoutParams = mainLayoutParams
|
||||||
|
|
||||||
|
val targetMargin = (400 * progress).toInt()
|
||||||
|
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||||
|
deviceTextParams.topMargin = targetMargin
|
||||||
|
deviceText.layoutParams = deviceTextParams
|
||||||
|
|
||||||
|
val baseTextSize = 24f
|
||||||
|
deviceText.textSize = baseTextSize + (progress * 8f)
|
||||||
|
|
||||||
|
val baseSubTextSize = 16f
|
||||||
|
connectedText.textSize = baseSubTextSize + (progress * 4f)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -133,21 +599,30 @@ class IslandWindow(context: Context) {
|
|||||||
if (isClosing) return
|
if (isClosing) return
|
||||||
isClosing = true
|
isClosing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(batteryReceiver)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
|
||||||
ServiceManager.getService()?.islandOpen = false
|
ServiceManager.getService()?.islandOpen = false
|
||||||
|
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||||
|
|
||||||
|
resetStretchEffects(0f)
|
||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
videoView.stopPlayback()
|
videoView.stopPlayback()
|
||||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
|
||||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
|
||||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
|
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
|
||||||
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
|
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||||
duration = 700
|
duration = 700
|
||||||
interpolator = AnticipateOvershootInterpolator()
|
interpolator = AnticipateOvershootInterpolator()
|
||||||
addListener(object : AnimatorListenerAdapter() {
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
override fun onAnimationEnd(animation: Animator) {
|
override fun onAnimationEnd(animation: Animator) {
|
||||||
islandView.visibility = View.GONE
|
containerView.visibility = View.GONE
|
||||||
try {
|
try {
|
||||||
windowManager.removeView(islandView)
|
windowManager.removeView(containerView)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e("IslandWindow", "Error removing view: $e")
|
e("IslandWindow", "Error removing view: $e")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -28,6 +30,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
object MediaController {
|
object MediaController {
|
||||||
private var initialVolume: Int? = null
|
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.
|
}, 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}")
|
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.")
|
Log.d("MediaController", "Pausing for cross device and taking over.")
|
||||||
sendPause(true)
|
sendPause(true)
|
||||||
pausedForCrossDevice = true
|
pausedForCrossDevice = true
|
||||||
ServiceManager.getService()?.takeOver()
|
ServiceManager.getService()?.takeOver("music")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getMusicActive(): Boolean {
|
||||||
|
return audioManager.isMusicActive
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun sendPause(force: Boolean = false) {
|
fun sendPause(force: Boolean = false) {
|
||||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||||
|
|||||||
@@ -136,10 +136,23 @@ class AirPodsNotifications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setStatus(data: ByteArray) {
|
fun setStatus(data: ByteArray) {
|
||||||
if (data.size != 11) {
|
when (data.size) {
|
||||||
return
|
// 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 =
|
val name: String =
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -32,6 +34,7 @@ import java.io.FileOutputStream
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@NoLiveLiterals
|
@NoLiveLiterals
|
||||||
class RadareOffsetFinder(context: Context) {
|
class RadareOffsetFinder(context: Context) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class BatteryWidget : AppWidgetProvider() {
|
class BatteryWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalEncodingApi::class)
|
||||||
|
|
||||||
package me.kavishdevar.librepods.widgets
|
package me.kavishdevar.librepods.widgets
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ import android.util.Log
|
|||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
class NoiseControlWidget : AppWidgetProvider() {
|
class NoiseControlWidget : AppWidgetProvider() {
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
@@ -79,7 +82,12 @@ class NoiseControlWidget : AppWidgetProvider() {
|
|||||||
if (intent.action == "ACTION_SET_ANC_MODE") {
|
if (intent.action == "ACTION_SET_ANC_MODE") {
|
||||||
val mode = intent.getIntExtra("ANC_MODE", 1)
|
val mode = intent.getIntExtra("ANC_MODE", 1)
|
||||||
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
|
||||||
ServiceManager.getService()?.setANCMode(mode)
|
ServiceManager.getService()!!
|
||||||
|
.aacpManager
|
||||||
|
.sendControlCommand(
|
||||||
|
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||||
|
mode.toByte()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/island_window_layout"
|
android:id="@+id/island_window_layout"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
android:layout_weight="0.95"
|
|
||||||
android:background="@drawable/island_background"
|
android:background="@drawable/island_background"
|
||||||
android:elevation="4dp"
|
android:elevation="4dp"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
@@ -38,12 +37,12 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
android:padding="0dp"
|
|
||||||
android:text="@string/island_connected_text"
|
|
||||||
android:textColor="#707072"
|
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:lineSpacingExtra="0dp"
|
android:lineSpacingExtra="0dp"
|
||||||
android:lineSpacingMultiplier="1"
|
android:lineSpacingMultiplier="1"
|
||||||
|
android:padding="0dp"
|
||||||
|
android:text="@string/island_connected_text"
|
||||||
|
android:textColor="#707072"
|
||||||
android:textSize="16sp" />
|
android:textSize="16sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
@@ -53,19 +52,20 @@
|
|||||||
android:layout_margin="0dp"
|
android:layout_margin="0dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="bottom"
|
android:gravity="bottom"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:lineSpacingExtra="0dp"
|
||||||
|
android:lineSpacingMultiplier="1"
|
||||||
android:padding="0dp"
|
android:padding="0dp"
|
||||||
android:text="AirPods Pro"
|
android:text="AirPods Pro"
|
||||||
android:textColor="@color/white"
|
android:textColor="@color/white"
|
||||||
android:textSize="24sp"
|
android:textSize="24sp"
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:lineSpacingExtra="0dp"
|
|
||||||
android:lineSpacingMultiplier="1"
|
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
android:id="@+id/island_battery_container"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -102,4 +102,4 @@
|
|||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
tools:ignore="HardcodedText" />
|
tools:ignore="HardcodedText" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -64,4 +64,19 @@
|
|||||||
<string name="collect_logs">Collect Logs</string>
|
<string name="collect_logs">Collect Logs</string>
|
||||||
<string name="saved_logs">Saved Logs</string>
|
<string name="saved_logs">Saved Logs</string>
|
||||||
<string name="no_logs_found">No saved logs found</string>
|
<string name="no_logs_found">No saved logs found</string>
|
||||||
|
<string name="takeover_header">Auto-Connect preferences</string>
|
||||||
|
<string name="takeover_airpods_state">Connect to your AirPods when its status is:</string>
|
||||||
|
<string name="takeover_disconnected">Disconnected</string>
|
||||||
|
<string name="takeover_disconnected_desc">AirPods are not connected to a device</string>
|
||||||
|
<string name="takeover_idle">Idle</string>
|
||||||
|
<string name="takeover_idle_desc">A device is connected to your AirPods, but not playing media or on a call</string>
|
||||||
|
<string name="takeover_music">Playing media</string>
|
||||||
|
<string name="takeover_music_desc">A device is playing media on your AirPods</string>
|
||||||
|
<string name="takeover_call">On call</string>
|
||||||
|
<string name="takeover_call_desc">A device is on a call with your AirPods</string>
|
||||||
|
<string name="takeover_phone_state">Connect to AirPods when your phone is:</string>
|
||||||
|
<string name="takeover_ringing_call">Receiving a call</string>
|
||||||
|
<string name="takeover_ringing_call_desc">Your phone starts ringing</string>
|
||||||
|
<string name="takeover_media_start">Starting media playback</string>
|
||||||
|
<string name="takeover_media_start_desc">Your phone starts playing media</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ hazeMaterials = "1.5.3"
|
|||||||
sliceBuilders = "1.1.0-alpha02"
|
sliceBuilders = "1.1.0-alpha02"
|
||||||
sliceCore = "1.1.0-alpha02"
|
sliceCore = "1.1.0-alpha02"
|
||||||
sliceView = "1.1.0-alpha02"
|
sliceView = "1.1.0-alpha02"
|
||||||
|
dynamicanimation = "1.1.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
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-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
|
||||||
androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
|
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-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
|
||||||
|
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user