add an app settings page for customizing functions and un-hardcode strings

This commit is contained in:
Kavish Devar
2025-01-09 03:25:32 +05:30
parent fc0475e2c0
commit f5cc47b53c
14 changed files with 794 additions and 95 deletions

View File

@@ -55,8 +55,10 @@ import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.aln.screens.AirPodsSettingsScreen
import me.kavishdevar.aln.screens.AppSettingsScreen
import me.kavishdevar.aln.screens.DebugScreen
import me.kavishdevar.aln.screens.LongPress
import me.kavishdevar.aln.screens.RenameScreen
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.aln.utils.AirPodsNotifications
@@ -158,7 +160,7 @@ fun Main() {
NavHost(
navController = navController,
startDestination = "settings",
startDestination = "app_settings",
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) },
@@ -183,6 +185,12 @@ fun Main() {
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") { navBackStackEntry ->
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
}
serviceConnection = remember {

View File

@@ -31,11 +31,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
@Composable
@@ -44,7 +46,7 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
val textColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = "ACCESSIBILITY",
text = stringResource(R.string.accessibility).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -68,7 +70,7 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
.padding(12.dp)
) {
Text(
text = "Tone Volume",
text = stringResource(R.string.tone_volume),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
@@ -95,4 +97,4 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
@Composable
fun AccessibilitySettingsPreview() {
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}
}

View File

@@ -31,11 +31,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
@Composable
@@ -44,7 +46,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
val textColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = "AUDIO",
text = stringResource(R.string.audio).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -72,7 +74,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
.padding(horizontal = 8.dp, vertical = 10.dp)
) {
Text(
text = "Adaptive Audio",
text = stringResource(R.string.adaptive_audio),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
@@ -82,7 +84,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
)
)
Text(
text = "Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.",
text = stringResource(R.string.adaptive_audio_description),
modifier = Modifier
.padding(bottom = 8.dp, top = 2.dp)
.padding(end = 2.dp, start = 2.dp)
@@ -102,4 +104,4 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
@Composable
fun AudioSettingsPreview() {
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.aln.R
@@ -110,7 +111,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
) {
Image (
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds),
contentDescription = "Buds",
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.scale(0.80f)
@@ -163,7 +164,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = "Case",
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.scale(1.25f)
@@ -181,4 +182,4 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
@Composable
fun BatteryViewPreview() {
BatteryView(AirPodsService(), preview = true)
}
}

View File

@@ -19,14 +19,20 @@
package me.kavishdevar.aln.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -39,15 +45,18 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
@Composable
fun StyledTextField(
fun NameField(
name: String,
value: String,
onValueChange: (String) -> Unit
navController: NavController
) {
var isFocused by remember { mutableStateOf(false) }
@@ -61,53 +70,72 @@ fun StyledTextField(
Color.Transparent
}
Row(
verticalAlignment = Alignment.CenterVertically,
Box (
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
color = textColor,
fontSize = 16.sp,
),
singleLine = true,
cursorBrush = SolidColor(cursorColor), // Dynamic cursor color based on focus
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
innerTextField()
.clickable(
onClick = {
navController.navigate("rename")
}
},
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused // Update focus state
.height(55.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = name,
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
BasicTextField(
value = value,
textStyle = TextStyle(
color = textColor.copy(alpha = 0.75f),
fontSize = 16.sp,
textAlign = TextAlign.End
),
onValueChange = {},
singleLine = true,
enabled = false,
cursorBrush = SolidColor(cursorColor),
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
innerTextField()
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Edit name",
tint = textColor.copy(alpha = 0.75f),
modifier = Modifier
.size(32.dp)
)
}
}
)
)
}
}
}
@Preview
@Composable
fun StyledTextFieldPreview() {
StyledTextField(name = "Name", value = "AirPods Pro", onValueChange = {})
NameField(name = "Name", value = "AirPods Pro", rememberNavController())
}

View File

@@ -39,7 +39,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -49,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -81,6 +81,7 @@ fun NoiseControlSettings(service: AirPodsService) {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
}
}
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -151,12 +152,12 @@ fun NoiseControlSettings(service: AirPodsService) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Text(
text = "NOISE CONTROL",
Text(// all caps
text = stringResource(R.string.noise_control).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
@@ -238,7 +239,7 @@ fun NoiseControlSettings(service: AirPodsService) {
) {
if (offListeningMode.value) {
Text(
text = "Off",
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
@@ -246,21 +247,21 @@ fun NoiseControlSettings(service: AirPodsService) {
)
}
Text(
text = "Transparency",
text = stringResource(R.string.transparency),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = "Adaptive",
text = stringResource(R.string.adaptive),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Text(
text = "Noise Cancellation",
text = stringResource(R.string.noise_cancellation),
style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,

View File

@@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -56,7 +57,7 @@ fun PressAndHoldSettings(navController: NavController) {
val textColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = "PRESS AND HOLD AIRPODS",
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -95,7 +96,7 @@ fun PressAndHoldSettings(navController: NavController) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Left",
text = stringResource(R.string.left),
style = TextStyle(
fontSize = 18.sp,
color = textColor,
@@ -105,7 +106,7 @@ fun PressAndHoldSettings(navController: NavController) {
Spacer(modifier = Modifier.weight(1f))
Text(
// TODO: Implement voice assistant on long press; for now, it's noise control
text = "Noise Control",
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -152,7 +153,7 @@ fun PressAndHoldSettings(navController: NavController) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Right",
text = stringResource(R.string.right),
style = TextStyle(
fontSize = 18.sp,
color = textColor,
@@ -162,7 +163,7 @@ fun PressAndHoldSettings(navController: NavController) {
Spacer(modifier = Modifier.weight(1f))
Text(
// TODO: Implement voice assistant on long press; for now, it's noise control
text = "Noise Control",
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -189,4 +190,4 @@ fun PressAndHoldSettings(navController: NavController) {
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}
}

View File

@@ -42,14 +42,23 @@ import androidx.compose.ui.unit.dp
@Composable
fun StyledSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
) {
val isDarkTheme = isSystemInDarkTheme()
val thumbColor = Color.White
val trackColor = if (checked) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val trackColor = if (enabled) (
if (isDarkTheme) {
if (checked) Color(0xFF34C759) else Color(0xFF5B5B5E)
} else {
if (checked) Color(0xFF34C759) else Color(0xFFD1D1D6)
}
) else {
if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
}
// Animate the horizontal offset of the thumb
val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test")
Box(
@@ -63,11 +72,11 @@ fun StyledSwitch(
) {
Box(
modifier = Modifier
.offset(x = thumbOffsetX) // Animate the offset for smooth transition
.offset(x = thumbOffsetX)
.size(27.dp)
.clip(CircleShape)
.background(thumbColor) // Dynamic thumb color
.clickable { onCheckedChange(!checked) } // Make the switch clickable
.background(thumbColor)
.clickable { if (enabled) onCheckedChange(!checked) }
)
}
}

View File

@@ -22,6 +22,7 @@ import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -33,7 +34,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -43,6 +44,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -55,6 +57,7 @@ import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -76,12 +79,11 @@ import me.kavishdevar.aln.composables.AccessibilitySettings
import me.kavishdevar.aln.composables.AudioSettings
import me.kavishdevar.aln.composables.BatteryView
import me.kavishdevar.aln.composables.IndependentToggle
import me.kavishdevar.aln.composables.NameField
import me.kavishdevar.aln.composables.NavigationButton
import me.kavishdevar.aln.composables.NoiseControlSettings
import me.kavishdevar.aln.composables.PressAndHoldSettings
import me.kavishdevar.aln.composables.StyledTextField
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.aln.utils.AirPodsNotifications
@@ -99,6 +101,23 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
)
}
val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") {
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
}
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
}
}
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
@@ -150,10 +169,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
containerColor = Color.Transparent
),
actions = {
val context = LocalContext.current
IconButton(
onClick = {
ServiceManager.restartService(context)
navController.navigate("app_settings")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
@@ -161,7 +179,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
) {
Icon(
imageVector = Icons.Default.Refresh,
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
)
}
@@ -199,14 +217,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(modifier = Modifier.height(32.dp))
StyledTextField(
name = "Name",
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
onValueChange = {
deviceName = TextFieldValue(it)
sharedPreferences.edit().putString("name", it).apply()
service.setName(it)
}
navController = navController
)
Spacer(modifier = Modifier.height(32.dp))

View File

@@ -0,0 +1,359 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.screens
import android.content.Context
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.fillMaxSize
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.aln.R
import me.kavishdevar.aln.composables.StyledSwitch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
val isDarkTheme = isSystemInDarkTheme()
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.app_settings),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
text = name.value,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 12.dp)
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
Column (
modifier = Modifier
.fillMaxWidth()
.height(275.sp.value.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
val sliderValue = remember { mutableFloatStateOf(0f) }
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("conversational_awareness_volume")) {
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("conversational_awareness_volume", sliderValue.floatValue.toInt()).apply()
}
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = stringResource(R.string.conversational_awareness_customization),
style = TextStyle(
fontSize = 20.sp,
color = textColor
),
modifier = Modifier
.padding(top = 12.dp, bottom = 4.dp)
)
var conversationalAwarenessPauseMusicEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness_pause_music", true)
)
}
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
conversationalAwarenessPauseMusicEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply()
}
var relativeConversationalAwarenessVolumeEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("relative_conversational_awareness_volume", true)
)
}
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
relativeConversationalAwarenessVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply()
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateConversationalAwarenessPauseMusic(!conversationalAwarenessPauseMusicEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness_pause_music),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.conversational_awareness_pause_music_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = conversationalAwarenessPauseMusicEnabled,
onCheckedChange = {
updateConversationalAwarenessPauseMusic(it)
},
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateRelativeConversationalAwarenessVolume(!relativeConversationalAwarenessVolumeEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.relative_conversational_awareness_volume),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.relative_conversational_awareness_volume_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = {
updateRelativeConversationalAwarenessVolume(it)
}
)
}
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
},
valueRange = 10f..85f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
},
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(((sliderValue.floatValue - 10) * 100) /7500)
.height(4.dp)
.background(if (conversationalAwarenessPauseMusicEnabled) trackColor else activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "10%",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(start = 4.dp)
)
Text(
text = "85%",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
}
}
@Preview
@Composable
fun AppSettingsScreenPreview() {
AppSettingsScreen(navController = NavController(LocalContext.current))
}

View File

@@ -0,0 +1,193 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RenameScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.name),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
text = name.value,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
var isFocused by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isFocused) { // Show cursor only when focused
if (isDarkTheme) Color.White else Color.Black
} else {
Color.Transparent
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BasicTextField(
value = name.value,
onValueChange = {
name.value = it
sharedPreferences.edit().putString("name", it).apply()
ServiceManager.getService()?.setName(it)
},
textStyle = TextStyle(
color = textColor,
fontSize = 16.sp,
),
singleLine = true,
cursorBrush = SolidColor(cursorColor),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
innerTextField()
}
IconButton(
onClick = {
name.value = ""
sharedPreferences.edit().putString("name", "").apply()
ServiceManager.getService()?.setName("")
}
) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
tint = if (isDarkTheme) Color.White else Color.Black
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
)
}
}
}
}
@Preview
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}

View File

@@ -36,7 +36,9 @@ import android.content.IntentFilter
import android.media.AudioManager
import android.os.Binder
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import android.widget.RemoteViews
@@ -473,6 +475,15 @@ class AirPodsService: Service() {
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
it.outputStream.flush()
delay(200)
// just in case this doesn't work, send all three after 5 seconds again
Handler(Looper.getMainLooper()).postDelayed({
it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.flush()
it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
it.outputStream.flush()
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
it.outputStream.flush()
}, 5000)
sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
.putExtra("device", device)
@@ -482,7 +493,7 @@ class AirPodsService: Service() {
socket.let {
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(audioManager)
MediaController.initialize(audioManager, this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE))
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
var data: ByteArray = byteArrayOf()
@@ -889,6 +900,7 @@ class AirPodsService: Service() {
socket.outputStream?.write(bytes)
socket.outputStream?.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
updateNotificationContent(true, name, batteryNotification.getBattery())
Log.d("AirPodsService", "setName: $name, sent packet: $hex")
}

View File

@@ -18,6 +18,7 @@
package me.kavishdevar.aln.utils
import android.content.SharedPreferences
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Handler
@@ -30,10 +31,35 @@ object MediaController {
private lateinit var audioManager: AudioManager
var iPausedTheMedia = false
var userPlayedTheMedia = false
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
fun initialize(audioManager: AudioManager) {
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
this.audioManager = audioManager
this.sharedPreferences = sharedPreferences
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
when (key) {
"relative_conversational_awareness_volume" -> {
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
}
"conversational_awareness_volume" -> {
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
}
"conversational_awareness_pause_music" -> {
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
}
}
}
audioManager.registerAudioPlaybackCallback(cb, null)
}
@@ -46,15 +72,15 @@ object MediaController {
handler.postDelayed({
iPausedTheMedia = !audioManager.isMusicActive
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why, but android sends a pause 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.
}
}
}
@Synchronized
fun sendPause() {
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
if (audioManager.isMusicActive && !userPlayedTheMedia) {
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
iPausedTheMedia = true
userPlayedTheMedia = false
audioManager.dispatchMediaKeyEvent(
@@ -99,8 +125,11 @@ object MediaController {
if (initialVolume == null) {
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
Log.d("MediaController", "Initial Volume Set: $initialVolume")
val targetVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 1 / 12
smoothVolumeTransition(initialVolume!!, targetVolume)
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
if (conversationalAwarenessPauseMusic) {
sendPause(force = true)
}
}
Log.d("MediaController", "Initial Volume: $initialVolume")
}
@@ -110,13 +139,16 @@ object MediaController {
Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume")
if (initialVolume != null) {
smoothVolumeTransition(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC), initialVolume!!)
if (conversationalAwarenessPauseMusic) {
sendPlay()
}
initialVolume = null
}
}
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
val step = if (fromVolume < toVolume) 1 else -1
val delay = 50L // 50 milliseconds delay between each step
val delay = 50L
var currentVolume = fromVolume
handler.post(object : Runnable {

View File

@@ -1,5 +1,42 @@
<resources>
<string name="app_name">ALN</string>
<string name="title_activity_custom_device">GATT Testing</string>
<string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
</resources>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string>
<string name="case_alt">Case</string>
<string name="test">Test</string>
<string name="name">Name</string>
<string name="noise_control">Noise Control</string>
<string name="off">Off</string>
<string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
</resources>