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