diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 067f5cb..0584667 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,11 +25,11 @@ To develop for the Android App, Android Studio is the preferred IDE. And you can #### Create a new issue -If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/username/AirPods-Like-Normal/issues). If no relevant issue exists, open a new one and fill in the details. +If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/aln/issues). If no relevant issue exists, open a new one and fill in the details. #### Solve an issue -Browse our [issues list](https://github.com/username/AirPods-Like-Normal/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution. +Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If you’d like to work on an issue, open a PR with your solution. ### Make Changes @@ -37,7 +37,7 @@ Browse our [issues list](https://github.com/username/AirPods-Like-Normal/issues) 1. Fork the repository and clone it to your local environment. ``` -git clone https://github.com/your-username/AirPods-Like-Normal.git +git clone https://github.com/kavishdevar/aln.git cd AirPods-Like-Normal ``` 2. Create a working branch to start your changes. @@ -67,4 +67,4 @@ Once your PR is open, a team member will review it. They may ask questions or re ### Your PR is merged! -Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal. +Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal. \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7e8b647..01003e0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + Unit, textColor: Color, - backgroundColor: Color, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + usePadding: Boolean = true ) { Column( modifier = modifier .fillMaxHeight() - .padding(horizontal = 4.dp, vertical = 4.dp) - .background(color = backgroundColor, shape = RoundedCornerShape(11.dp)) + .then(if (usePadding) Modifier.padding(horizontal = 4.dp, vertical = 4.dp) else Modifier) .clickable( onClick = onClick, indication = null, - interactionSource = remember { MutableInteractionSource() }), + interactionSource = remember { MutableInteractionSource() } + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -75,6 +73,5 @@ fun NoiseControlButtonPreview() { icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), onClick = {}, textColor = Color.White, - backgroundColor = Color.Black ) } \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt index aa9edbd..c5e08a2 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/NoiseControlSettings.kt @@ -25,42 +25,60 @@ import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.os.Build +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity 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 import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import me.kavishdevar.aln.R import me.kavishdevar.aln.services.AirPodsService import me.kavishdevar.aln.utils.AirPodsNotifications import me.kavishdevar.aln.utils.NoiseControlMode +import kotlin.math.roundToInt -@SuppressLint("UnspecifiedRegisterReceiverFlag") +@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @Composable fun NoiseControlSettings(service: AirPodsService) { val context = LocalContext.current @@ -86,7 +104,7 @@ fun NoiseControlSettings(service: AirPodsService) { val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val textColor = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black - val selectedBackground = if (isDarkTheme) Color(0xFF5C5A5F) else Color(0xFFFFFFFF) + val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } @@ -161,118 +179,258 @@ fun NoiseControlSettings(service: AirPodsService) { ), modifier = Modifier.padding(8.dp, bottom = 2.dp) ) - - Column( + BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp) + .padding(vertical = 8.dp) // Adjusted padding ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(75.dp) - .padding(8.dp) + val density = LocalDensity.current + val buttonCount = if (offListeningMode.value) 4 else 3 + val buttonWidth = maxWidth / buttonCount + + val isDragging = remember { mutableStateOf(false) } + var dragOffset by remember { + mutableFloatStateOf( + with(density) { + when(noiseControlMode.value) { + NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx() + NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f + NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx() + NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() + } + } + ) + } + + val animationSpec: AnimationSpec = SpringSpec( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 0.01f + ) + + val targetOffset = buttonWidth * when(noiseControlMode.value) { + NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1 + NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0 + NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1 + NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2 + } + + val animatedOffset by animateFloatAsState( + targetValue = with(density) { + if (isDragging.value) dragOffset else targetOffset.toPx() + }, + animationSpec = animationSpec, + label = "selector" + ) + + Column( + modifier = Modifier.fillMaxWidth() ) { - Row( + Box( modifier = Modifier .fillMaxWidth() + .height(60.dp) // Adjusted height .background(backgroundColor, RoundedCornerShape(14.dp)) ) { - if (offListeningMode.value) { + // First: Background Row (just for visual) + Row( + modifier = Modifier.fillMaxWidth() + ) { + if (offListeningMode.value) { + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.OFF) }, + textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d1a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + } NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.OFF) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) + icon = ImageBitmap.imageResource(R.drawable.transparency), + onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, + textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false ) VerticalDivider( thickness = 1.dp, modifier = Modifier .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + .alpha(d2a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.adaptive), + onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, + textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d3a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, + textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false ) } - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - VerticalDivider( - thickness = 1.dp, + + Box( modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - VerticalDivider( - thickness = 1.dp, + .width(buttonWidth) + .fillMaxHeight() + .offset { IntOffset(animatedOffset.roundToInt(), 0) } + .zIndex(0f) + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + dragOffset = (dragOffset + delta).coerceIn( + 0f, + with(density) { (buttonWidth * (buttonCount - 1)).toPx() } + ) + }, + onDragStarted = { isDragging.value = true }, + onDragStopped = { + isDragging.value = false + val position = dragOffset / with(density) { buttonWidth.toPx() } + val newIndex = position.roundToInt() + val newMode = when(newIndex) { + 0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY + 1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE + 2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION + 3 -> NoiseControlMode.NOISE_CANCELLATION + else -> null + } + newMode?.let { onModeSelected(it) } + } + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(4.dp) + .background(selectedBackground, RoundedCornerShape(11.dp)) + ) + } + + // Button row (top layer) + Row( modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) + .fillMaxWidth() + .zIndex(1f) + ) { + if (offListeningMode.value) { + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.OFF) }, + textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d1a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + } + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.transparency), + onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, + textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d2a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.adaptive), + onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, + textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d3a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, + textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + } } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 1.dp) - ) { - if (offListeningMode.value) { + + // Labels row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 2.dp) + ) { + if (offListeningMode.value) { + Text( + text = stringResource(R.string.off), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + } Text( - text = stringResource(R.string.off), + text = stringResource(R.string.transparency), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.adaptive), + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = stringResource(R.string.noise_cancellation), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f) ) } - Text( - text = stringResource(R.string.transparency), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.adaptive), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.noise_cancellation), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) } } } -@Preview -@Composable +@Preview@Composable fun NoiseControlSettingsPreview() { NoiseControlSettings(AirPodsService()) } + diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt index 8f89570..ba368ce 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/PressAndHoldSettings.kt @@ -18,8 +18,10 @@ package me.kavishdevar.aln.composables +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,9 +38,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.res.stringResource import androidx.compose.ui.text.TextStyle @@ -55,6 +62,13 @@ import me.kavishdevar.aln.R fun PressAndHoldSettings(navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black + val dividerColor = Color(0x40888888) + var leftBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + var rightBackgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + + val animationSpec = tween(durationMillis = 500) + val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec) + val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec) Text( text = stringResource(R.string.press_and_hold_airpods).uppercase(), @@ -67,27 +81,28 @@ fun PressAndHoldSettings(navController: NavController) { modifier = Modifier.padding(8.dp, bottom = 2.dp) ) - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - Column( modifier = Modifier .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(14.dp)) - .padding(top = 2.dp) + .background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)) ) { Box( modifier = Modifier .fillMaxWidth() .height(55.dp) - .background( - backgroundColor, - RoundedCornerShape(14.dp) - ) - .clickable( - onClick = { - navController.navigate("long_press/Left") - } - ), + .background(animatedLeftBackgroundColor, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + leftBackgroundColor = dividerColor + tryAwaitRelease() + leftBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + navController.navigate("long_press/Left") + } + ) + }, contentAlignment = Alignment.Center ) { Row( @@ -105,7 +120,6 @@ fun PressAndHoldSettings(navController: NavController) { ) Spacer(modifier = Modifier.weight(1f)) Text( - // TODO: Implement voice assistant on long press; for now, it's noise control text = stringResource(R.string.noise_control), style = TextStyle( fontSize = 18.sp, @@ -128,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) { } HorizontalDivider( thickness = 1.5.dp, - color = Color(0x40888888), + color = dividerColor, modifier = Modifier .padding(start = 16.dp) ) @@ -136,15 +150,19 @@ fun PressAndHoldSettings(navController: NavController) { modifier = Modifier .fillMaxWidth() .height(55.dp) - .background( - backgroundColor, - RoundedCornerShape(18.dp) - ) - .clickable( - onClick = { - navController.navigate("long_press/Right") - } - ), + .background(animatedRightBackgroundColor, RoundedCornerShape(bottomEnd = 14.dp, bottomStart = 14.dp)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + rightBackgroundColor = dividerColor + tryAwaitRelease() + rightBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + navController.navigate("long_press/Right") + } + ) + }, contentAlignment = Alignment.Center ) { Row( @@ -162,7 +180,6 @@ fun PressAndHoldSettings(navController: NavController) { ) Spacer(modifier = Modifier.weight(1f)) Text( - // TODO: Implement voice assistant on long press; for now, it's noise control text = stringResource(R.string.noise_control), style = TextStyle( fontSize = 18.sp, diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt index 2c76372..67ec73e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/screens/DebugScreen.kt @@ -21,19 +21,15 @@ package me.kavishdevar.aln.screens import android.annotation.SuppressLint -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.ServiceConnection -import android.os.Build import android.os.IBinder import android.util.Log 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.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -43,17 +39,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -62,7 +64,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -83,18 +85,36 @@ import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.flow.MutableStateFlow import me.kavishdevar.aln.R import me.kavishdevar.aln.services.AirPodsService -import me.kavishdevar.aln.utils.AirPodsNotifications @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @Composable fun DebugScreen(navController: NavController) { val hazeState = remember { HazeState() } - val text = remember { mutableStateListOf("Log Start") } val context = LocalContext.current val listState = rememberLazyListState() + val packetLogsFlow = remember { MutableStateFlow(emptySet()) } + val expandedItems = remember { mutableStateOf(setOf()) } + + LaunchedEffect(context) { + val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + val binder = service as AirPodsService.LocalBinder + val airPodsService = binder.getService() + packetLogsFlow.value = airPodsService.getPacketLogs() + } + + override fun onServiceDisconnected(name: ComponentName) {} + } + + val intent = Intent(context, AirPodsService::class.java) + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + + val packetLogs = packetLogsFlow.collectAsState(setOf()).value Scaffold( topBar = { @@ -145,29 +165,6 @@ fun DebugScreen(navController: NavController) { containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), ) { paddingValues -> - val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val data = intent.getByteArrayExtra("data") - data?.let { - text.add(">" + it.joinToString(" ") { byte -> "%02X".format(byte) }) - } - } - } - - LaunchedEffect(context) { - val intentFilter = IntentFilter(AirPodsNotifications.Companion.AIRPODS_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(receiver, intentFilter) - } - } - - LaunchedEffect(text.size) { - if (text.isNotEmpty()) { - listState.animateScrollToItem(text.size - 1) - } - } Column( modifier = Modifier .fillMaxSize() @@ -181,44 +178,53 @@ fun DebugScreen(navController: NavController) { .fillMaxWidth() .weight(1f), content = { - items(text.size) { index -> - val message = text[index] - val isSent = message.startsWith(">") - val backgroundColor = - if (isSent) Color(0xFFE1FFC7) else Color(0xFFD1D1D1) + items(packetLogs.size) { index -> + val message = packetLogs.elementAt(index) + val isSent = message.startsWith("Sent") + val isExpanded = expandedItems.value.contains(index) - if (message == "Log Start") { - Spacer(modifier = Modifier.height(115.dp)) - } - - Box( + Card( modifier = Modifier .fillMaxWidth() - .padding(8.dp) - .background(backgroundColor, RoundedCornerShape(12.dp)) - .padding(12.dp), + .padding(vertical = 2.dp, horizontal = 4.dp) // Reduced padding + .clickable { + expandedItems.value = if (isExpanded) { + expandedItems.value - index + } else { + expandedItems.value + index + } + }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), // Reduced elevation + shape = RoundedCornerShape(4.dp), // Reduced corner radius + colors = CardDefaults.cardColors( + containerColor = Color.Transparent + ) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (!isSent) { - Text("<", color = Color(0xFF00796B), fontSize = 16.sp) - } - - Text( - text = if (isSent) message.substring(1) else message, - fontFamily = FontFamily(Font(R.font.hack)), - color = if (isSystemInDarkTheme()) Color( - 0xFF000000 + Column(modifier = Modifier.padding(8.dp)) { // Reduced padding + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = if (isSent) Color.Green else Color.Red, + modifier = Modifier.size(24.dp) // Reduced icon size ) - else Color(0xFF000000), - modifier = Modifier.weight(1f) - ) - - if (isSent) { - Text(">", color = Color(0xFF00796B), fontSize = 16.sp) + Spacer(modifier = Modifier.width(4.dp)) // Reduced spacing + Column { + Text( + text = + if (isSent) message.substring(5).take(60) + (if (message.substring(5).length > 60) "..." else "") + else message.substring(9).take(60) + (if (message.substring(9).length > 60) "..." else ""), + style = MaterialTheme.typography.bodySmall, + ) + if (isExpanded) { + Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing + Text( + text = message.substring(if (isSent) 5 else 9), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } } } } @@ -262,7 +268,6 @@ fun DebugScreen(navController: NavController) { IconButton( onClick = { airPodsService.value?.sendPacket(packet.value.text) - text.add(packet.value.text) packet.value = TextFieldValue("") } ) { @@ -282,23 +287,6 @@ fun DebugScreen(navController: NavController) { ), shape = RoundedCornerShape(12.dp) ) - - val airPodsService = remember { mutableStateOf(null) } - - val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) { - val binder = service as AirPodsService.LocalBinder - airPodsService.value = binder.getService() - Log.d("AirPodsService", "Service connected") - } - - override fun onServiceDisconnected(name: ComponentName) { - airPodsService.value = null - } - } - - val intent = Intent(context, AirPodsService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) } } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt index 40be85a..7d1e371 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/screens/RenameScreen.kt @@ -41,16 +41,18 @@ 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.LaunchedEffect 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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -69,6 +71,14 @@ fun RenameScreen(navController: NavController) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val isDarkTheme = isSystemInDarkTheme() val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") } + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -117,17 +127,10 @@ fun RenameScreen(navController: NavController) { .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 - } - + val cursorColor = if (isDarkTheme) Color.White else Color.Black Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -180,6 +183,7 @@ fun RenameScreen(navController: NavController) { modifier = Modifier .fillMaxWidth() .padding(start = 8.dp) + .focusRequester(focusRequester) ) } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt index 6f35633..b9c1b2b 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt @@ -39,6 +39,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.SharedPreferences import android.media.AudioManager import android.os.Binder import android.os.Build @@ -95,6 +96,7 @@ object ServiceManager { delay(1000) context.startService(intent) context.startActivity(Intent(context, MainActivity::class.java)) + service?.clearLogs() } } } @@ -106,6 +108,34 @@ class AirPodsService: Service() { fun getService(): AirPodsService = this@AirPodsService } + private lateinit var sharedPreferences: SharedPreferences + private val packetLogKey = "packet_log" + + override fun onCreate() { + super.onCreate() + sharedPreferences = getSharedPreferences("packet_logs", Context.MODE_PRIVATE) + } + + private fun logPacket(packet: ByteArray, source: String) { + val packetHex = packet.joinToString(" ") { "%02X".format(it) } + val logEntry = "$source: $packetHex" + val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf() + logs.add(logEntry) + sharedPreferences.edit().putStringSet(packetLogKey, logs).apply() + } + + fun getPacketLogs(): Set { + return sharedPreferences.getStringSet(packetLogKey, emptySet()) ?: emptySet() + } + + private fun clearPacketLogs() { + sharedPreferences.edit().remove(packetLogKey).apply() + } + + fun clearLogs() { + clearPacketLogs() // Expose a method to clear logs + } + override fun onBind(intent: Intent?): IBinder { return LocalBinder() } @@ -133,7 +163,9 @@ class AirPodsService: Service() { } } private fun forwardPacket(packet: String, outputStream: OutputStream) { - outputStream.write(packet.toByteArray()) + val byteArray = packet.toByteArray() + outputStream.write(byteArray) + logPacket(byteArray, "Sent") } private fun connectToAirPods() { @@ -400,7 +432,7 @@ class AirPodsService: Service() { .putString("name", name).apply() } Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString()) - if (!CrossDevice.isAvailable) { + if (!CrossDevice.checkAirPodsConnectionStatus()) { Log.d("AirPodsService", "$name connected") showPopup(this@AirPodsService, name.toString()) connectToSocket(device!!) @@ -463,7 +495,7 @@ class AirPodsService: Service() { if (profile == BluetoothProfile.A2DP) { val connectedDevices = proxy.connectedDevices if (connectedDevices.isNotEmpty()) { - if (!CrossDevice.isAvailable) { + if (!CrossDevice.checkAirPodsConnectionStatus()) { connectToSocket(device) } this@AirPodsService.sendBroadcast( @@ -482,6 +514,10 @@ class AirPodsService: Service() { } } + if (!isConnectedLocally && !CrossDevice.isAvailable) { + clearPacketLogs() // Clear logs when device is not available + } + return START_STICKY } @@ -581,6 +617,7 @@ class AirPodsService: Service() { var data: ByteArray = byteArrayOf() if (bytesRead > 0) { data = buffer.copyOfRange(0, bytesRead) + logPacket(data, "AirPods") sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DATA).apply { putExtra("data", buffer.copyOfRange(0, bytesRead)) }) @@ -824,8 +861,10 @@ class AirPodsService: Service() { return } if (this::socket.isInitialized) { - socket.outputStream?.write(fromHex.toByteArray()) + val byteArray = fromHex.toByteArray() + socket.outputStream?.write(byteArray) socket.outputStream?.flush() + logPacket(byteArray, "Sent") } } @@ -837,6 +876,7 @@ class AirPodsService: Service() { if (this::socket.isInitialized) { socket.outputStream?.write(packet) socket.outputStream?.flush() + logPacket(packet, "Sent") } } @@ -1015,7 +1055,6 @@ class AirPodsService: Service() { val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01, nameBytes.size.toByte(), 0x00) + nameBytes sendPacket(bytes) - socket.outputStream?.flush() val hex = bytes.joinToString(" ") { "%02X".format(it) } updateNotificationContent(true, name, batteryNotification.getBattery()) Log.d("AirPodsService", "setName: $name, sent packet: $hex") @@ -1025,18 +1064,15 @@ class AirPodsService: Service() { var hex = "04 00 04 00 09 00 26 ${if (enabled) "01" else "02"} 00 00 00" var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() sendPacket(bytes) - socket.outputStream?.flush() hex = "04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00" bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() sendPacket(bytes) - socket.outputStream?.flush() } fun setLoudSoundReduction(enabled: Boolean) { val hex = "52 1B 00 0${if (enabled) "1" else "0"}" val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() sendPacket(bytes) - socket.outputStream?.flush() } fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int { for (i in oldArray.indices) { @@ -1175,11 +1211,12 @@ class AirPodsService: Service() { } packet?.let { Log.d("AirPodsService", "Sending packet: ${it.joinToString(" ") { "%02X".format(it) }}") - socket.outputStream.write(it) + sendPacket(it) } } override fun onDestroy() { + clearPacketLogs() Log.d("AirPodsService", "Service stopped is being destroyed for some reason!") try { unregisterReceiver(bluetoothReceiver) diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt index a7b20aa..9815070 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt @@ -5,8 +5,13 @@ import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothServerSocket import android.bluetooth.BluetoothSocket +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser import android.content.Context import android.content.SharedPreferences +import android.os.ParcelUuid import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,26 +26,36 @@ enum class CrossDevicePackets(val packet: ByteArray) { REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)), REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)), REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)), + REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)), AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)), } object CrossDevice { + var initialized = false private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342") private var serverSocket: BluetoothServerSocket? = null private var clientSocket: BluetoothSocket? = null private lateinit var bluetoothAdapter: BluetoothAdapter + private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser + private const val MANUFACTURER_ID = 0x1234 + private const val MANUFACTURER_DATA = "ALN_AirPods" var isAvailable: Boolean = false // set to true when airpods are connected to another device var batteryBytes: ByteArray = byteArrayOf() var ancBytes: ByteArray = byteArrayOf() private lateinit var sharedPreferences: SharedPreferences + private const val packetLogKey = "packet_log" + @SuppressLint("MissingPermission") fun init(context: Context) { Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice") - sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE) sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser + startAdvertising() startServer() + initialized = true } @SuppressLint("MissingPermission") @@ -59,6 +74,34 @@ object CrossDevice { } } + @SuppressLint("MissingPermission") + private fun startAdvertising() { + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) + .setConnectable(true) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray()) + .addServiceUuid(ParcelUuid(uuid)) + .build() + + bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback) + Log.d("AirPodsQuickSwitchService", "BLE Advertising started") + } + + private val advertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + Log.d("AirPodsQuickSwitchService", "BLE Advertising started successfully") + } + + override fun onStartFailure(errorCode: Int) { + Log.e("AirPodsQuickSwitchService", "BLE Advertising failed with error code: $errorCode") + } + } + fun setAirPodsConnected(connected: Boolean) { if (connected) { isAvailable = false @@ -71,9 +114,21 @@ object CrossDevice { fun sendReceivedPacket(packet: ByteArray) { Log.d("AirPodsQuickSwitchService", "Sending packet to remote device") + if (clientSocket == null) { + Log.d("AirPodsQuickSwitchService", "Client socket is null") + return + } clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) } + private fun logPacket(packet: ByteArray, source: String) { + val packetHex = packet.joinToString(" ") { "%02X".format(it) } + val logEntry = "$source: $packetHex" + val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf() + logs.add(logEntry) + sharedPreferences.edit().putStringSet(packetLogKey, logs).apply() + } + @SuppressLint("MissingPermission") private fun handleClientConnection(socket: BluetoothSocket) { Log.d("AirPodsQuickSwitchService", "Client connected") @@ -85,6 +140,7 @@ object CrossDevice { while (true) { bytes = inputStream.read(buffer) val packet = buffer.copyOf(bytes) + logPacket(packet, "Relay") Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}") if (bytes == -1) { break @@ -102,19 +158,17 @@ object CrossDevice { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) { Log.d("AirPodsQuickSwitchService", "Received ANC request") sendRemotePacket(ancBytes) + } else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) { + Log.d("AirPodsQuickSwitchService", "Received connection status request") + sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet) } else { if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { - // the AIRPODS_CONNECTED wasn't sent before isAvailable = true sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() - val trimmedPacket = - packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray() + val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray() Log.d("AirPodsQuickSwitchService", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket)}") - Log.d( - "AirPodsQuickSwitchService", - "Relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}" - ) + Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}") if (ServiceManager.getService()?.isConnectedLocally == true) { val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) } ServiceManager.getService()?.sendPacket(packetInHex) @@ -142,6 +196,25 @@ object CrossDevice { } clientSocket?.outputStream?.write(byteArray) clientSocket?.outputStream?.flush() + logPacket(byteArray, "Sent") Log.d("AirPodsQuickSwitchService", "Sent packet to remote device") } + + fun checkAirPodsConnectionStatus(): Boolean { + Log.d("AirPodsQuickSwitchService", "Checking AirPods connection status") + if (clientSocket == null) { + Log.d("AirPodsQuickSwitchService", "Client socket is null - linux probably not connected.") + return false + } + return try { + clientSocket?.outputStream?.write(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet) + val buffer = ByteArray(1024) + val bytes = clientSocket?.inputStream?.read(buffer) ?: -1 + val packet = buffer.copyOf(bytes) + packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet) + } catch (e: IOException) { + Log.e("AirPodsQuickSwitchService", "Error checking connection status", e) + false + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt index 96cce84..6a3d845 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/utils/Packets.kt @@ -157,8 +157,13 @@ class AirPodsNotifications { private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) fun isBatteryData(data: ByteArray): Boolean { + if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) { + Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.") + } else { + return false + } if (data.size != 22) { - Log.d("BatteryNotification", "Battery data size is not 22") + Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.") return false } Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString()) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b2af026..9c76639 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanistPermissions = "0.36.0" -agp = "8.7.3" +agp = "8.8.0" hiddenapibypass = "4.3" kotlin = "2.0.0" coreKtx = "1.15.0" diff --git a/linux/main.cpp b/linux/main.cpp index fc6f1fe..d4b5908 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -39,6 +39,10 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp") #define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0" +// Define Manufacturer Specific Data Identifier +#define MANUFACTURER_ID 0x1234 +#define MANUFACTURER_DATA "ALN_AirPods" + class AirPodsTrayApp : public QObject { Q_OBJECT @@ -88,6 +92,8 @@ public: connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated); discoveryAgent = new QBluetoothDeviceDiscoveryAgent(); + discoveryAgent->setLowEnergyDiscoveryTimeout(5000); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished); discoveryAgent->start(); @@ -107,6 +113,16 @@ public: } initializeMprisInterface(); connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived); + + // After starting discovery, check if service record exists + QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1"); + QDBusReply reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a")); + if (reply.isValid()) { + LOG_INFO("Service record found, proceeding with connection"); + // Proceed with existing connection logic + } else { + LOG_WARN("Service record not found, waiting for BLE broadcast"); + } } public slots: @@ -288,6 +304,12 @@ public slots: } void onDeviceDiscovered(const QBluetoothDeviceInfo &device) { + QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID); + if (manufacturerData.startsWith(MANUFACTURER_DATA)) { + LOG_INFO("Detected AirPods via BLE manufacturer data"); + // Initiate RFComm connection + connectToDevice(device.address().toString()); + } LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")"); if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { LOG_DEBUG("Found AirPods device" + device.name()); @@ -297,6 +319,8 @@ public slots: void onDiscoveryFinished() { LOG_INFO("Device discovery finished"); + // Restart discovery to continuously listen for broadcasts + discoveryAgent->start(); const QList discoveredDevices = discoveryAgent->discoveredDevices(); for (const QBluetoothDeviceInfo &device : discoveredDevices) { if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { @@ -334,60 +358,79 @@ public slots: LOG_INFO("Already connected to the device: " << device.name()); return; } - - LOG_INFO("Connecting to device: " << device.name() << " (" << device.address().toString() << ")"); - QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); - connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() { - LOG_INFO("Connected to device, sending initial packets"); - discoveryAgent->stop(); - - QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000"); - QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000"); - QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff"); - - qint64 bytesWritten = localSocket->write(handshakePacket); - LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten); - - QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001"); - phoneSocket->write(airpodsConnectedPacket); - LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex()); - - connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) { - LOG_INFO("Bytes written: " << bytes); - if (bytes > 0) { - static int step = 0; - switch (step) { - case 0: - localSocket->write(setSpecificFeaturesPacket); - LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex()); - step++; - break; - case 1: - localSocket->write(requestNotificationsPacket); - LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex()); - step++; - break; - } + + LOG_INFO("Checking connection status with phone before connecting to device: " << device.name()); + QByteArray connectionStatusRequest = QByteArray::fromHex("00020003"); + if (phoneSocket && phoneSocket->isOpen()) { + phoneSocket->write(connectionStatusRequest); + LOG_DEBUG("Connection status request packet written: " << connectionStatusRequest.toHex()); + connect(phoneSocket, &QBluetoothSocket::readyRead, this, [this, device]() { + QByteArray data = phoneSocket->read(4); + LOG_DEBUG("Data received from phone: " << data.toHex()); + if (data == QByteArray::fromHex("00010001")) { + LOG_INFO("AirPods are already connected"); + disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr); + } else if (data == QByteArray::fromHex("00010000")) { + LOG_INFO("AirPods are disconnected, proceeding with connection"); + disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr); + + QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); + connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() { + LOG_INFO("Connected to device, sending initial packets"); + discoveryAgent->stop(); + + QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000"); + QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000"); + QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff"); + + qint64 bytesWritten = localSocket->write(handshakePacket); + LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten); + + QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001"); + phoneSocket->write(airpodsConnectedPacket); + LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex()); + + connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) { + LOG_INFO("Bytes written: " << bytes); + if (bytes > 0) { + static int step = 0; + switch (step) { + case 0: + localSocket->write(setSpecificFeaturesPacket); + LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex()); + step++; + break; + case 1: + localSocket->write(requestNotificationsPacket); + LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex()); + step++; + break; + } + } + }); + + connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() { + QByteArray data = localSocket->readAll(); + LOG_DEBUG("Data received: " << data.toHex()); + parseData(data); + relayPacketToPhone(data); + }); + }); + + connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) { + LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString()); + }); + + localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); + socket = localSocket; + connectedDeviceMacAddress = device.address().toString().replace(":", "_"); } }); - - connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() { - QByteArray data = localSocket->readAll(); - LOG_DEBUG("Data received: " << data.toHex()); - parseData(data); - relayPacketToPhone(data); - }); - }); - - connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) { - LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString()); - }); - - localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); - socket = localSocket; - connectedDeviceMacAddress = device.address().toString().replace(":", "_"); + } else { + LOG_ERROR("Phone socket is not open, cannot send connection status request"); + } } - + void parseData(const QByteArray &data) { LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size()); if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) { @@ -416,7 +459,6 @@ public slots: .arg(caseLevel); LOG_INFO("Battery status: " << batteryStatus); emit batteryStatusChanged(batteryStatus); - relayPacketToPhone(data); } else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) { LOG_INFO("Received conversational awareness data"); @@ -436,9 +478,6 @@ public slots: process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@"); process.waitForFinished(); QString output = process.readAllStandardOutput(); - // Volume: front-left: 12843 / 20% / -42.47 dB, front-right: 12843 / 20% / -42.47 dB - // balance 0.00 - QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%"); QRegularExpressionMatch match = re.match(output); if (match.hasMatch()) { @@ -513,6 +552,7 @@ public slots: phoneSocket->write(header + packet); LOG_DEBUG("Relayed packet to phone with header: " << (header + packet).toHex()); } else { + connectToPhone(); LOG_WARN("Phone socket is not open, cannot relay packet"); } } @@ -532,6 +572,11 @@ public slots: } else if (packet.startsWith(QByteArray::fromHex("00010000"))) { LOG_INFO("AirPods disconnected"); // Handle AirPods disconnected + } else if (packet.startsWith(QByteArray::fromHex("00020003"))) { + LOG_INFO("Connection status request received"); + QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000"); + phoneSocket->write(response); + LOG_DEBUG("Sent connection status response: " << response.toHex()); } else { if (socket && socket->isOpen()) { socket->write(packet); diff --git a/root-module-manual/btl2capfix.zip b/root-module-manual/btl2capfix.zip deleted file mode 100644 index c61433a..0000000 Binary files a/root-module-manual/btl2capfix.zip and /dev/null differ diff --git a/root-module-manual/post-data-fs.sh b/root-module-manual/post-data-fs.sh new file mode 100644 index 0000000..ad2ab8b --- /dev/null +++ b/root-module-manual/post-data-fs.sh @@ -0,0 +1,2 @@ +#!/system/bin/sh +mount -t overlay overlay -o lowerdir=/apex/com.android.btservices/lib64/,upperdir=/data/adb/modules/btl2capfix/apex/com.android.btservices/lib64,workdir=/data/adb/modules/btl2capfix/apex/com.android.btservices/work Zapex/com.android.btservices/lib64