move files across computers

This commit is contained in:
Kavish Devar
2025-01-25 00:00:43 +05:30
parent 938278b0b5
commit a6d7bd704a
17 changed files with 628 additions and 290 deletions

View File

@@ -25,11 +25,11 @@ To develop for the Android App, Android Studio is the preferred IDE. And you can
#### Create a new issue #### 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 #### 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 youd 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 youd like to work on an issue, open a PR with your solution.
### Make Changes ### 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. 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 cd AirPods-Like-Normal
``` ```
2. Create a working branch to start your changes. 2. Create a working branch to start your changes.

View File

@@ -18,6 +18,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" <uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" /> tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View File

@@ -146,6 +146,15 @@ fun Main() {
isRemotelyConnected.value = CrossDevice.isAvailable isRemotelyConnected.value = CrossDevice.isAvailable
isConnected.value = false isConnected.value = false
} }
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
Log.d("MainActivity", "Disconnect Receivers intent received")
try {
context.unregisterReceiver(this)
}
catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
}
} }
} }
@@ -159,10 +168,12 @@ fun Main() {
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener) sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}") Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
val filter = IntentFilter().apply { val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED) addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED) addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
} }
Log.d("MainActivity", "Registering Receiver") Log.d("MainActivity", "Registering Receiver")

View File

@@ -148,7 +148,7 @@ fun DropdownMenuComponent(
Column ( Column (
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp) .padding(horizontal = 12.dp)
) { ) {
Text( Text(
text = label, text = label,

View File

@@ -82,11 +82,11 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
sliderValue.floatValue = it sliderValue.floatValue = it
service.setAdaptiveStrength(100 - it.toInt())
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
onValueChangeFinished = { onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -18,7 +18,6 @@
package me.kavishdevar.aln.composables package me.kavishdevar.aln.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -26,7 +25,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -44,18 +42,18 @@ fun NoiseControlButton(
icon: ImageBitmap, icon: ImageBitmap,
onClick: () -> Unit, onClick: () -> Unit,
textColor: Color, textColor: Color,
backgroundColor: Color, modifier: Modifier = Modifier,
modifier: Modifier = Modifier usePadding: Boolean = true
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.padding(horizontal = 4.dp, vertical = 4.dp) .then(if (usePadding) Modifier.padding(horizontal = 4.dp, vertical = 4.dp) else Modifier)
.background(color = backgroundColor, shape = RoundedCornerShape(11.dp))
.clickable( .clickable(
onClick = onClick, onClick = onClick,
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }), interactionSource = remember { MutableInteractionSource() }
),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@@ -75,6 +73,5 @@ fun NoiseControlButtonPreview() {
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = {}, onClick = {},
textColor = Color.White, textColor = Color.White,
backgroundColor = Color.Black
) )
} }

View File

@@ -25,42 +25,60 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build 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.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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.utils.AirPodsNotifications import me.kavishdevar.aln.utils.AirPodsNotifications
import me.kavishdevar.aln.utils.NoiseControlMode import me.kavishdevar.aln.utils.NoiseControlMode
import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings(service: AirPodsService) { fun NoiseControlSettings(service: AirPodsService) {
val context = LocalContext.current val context = LocalContext.current
@@ -86,7 +104,7 @@ fun NoiseControlSettings(service: AirPodsService) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = 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) } val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
@@ -161,118 +179,258 @@ fun NoiseControlSettings(service: AirPodsService) {
), ),
modifier = Modifier.padding(8.dp, bottom = 2.dp) modifier = Modifier.padding(8.dp, bottom = 2.dp)
) )
BoxWithConstraints(
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 16.dp) .padding(vertical = 8.dp) // Adjusted padding
) { ) {
Box( val density = LocalDensity.current
modifier = Modifier val buttonCount = if (offListeningMode.value) 4 else 3
.fillMaxWidth() val buttonWidth = maxWidth / buttonCount
.height(75.dp)
.padding(8.dp) 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<Float> = 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(60.dp) // Adjusted height
.background(backgroundColor, RoundedCornerShape(14.dp)) .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( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.transparency),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.OFF) selectedBackground else Color.Transparent, modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f) usePadding = false
) )
VerticalDivider( VerticalDivider(
thickness = 1.dp, thickness = 1.dp,
modifier = Modifier modifier = Modifier
.padding(vertical = 10.dp) .padding(vertical = 10.dp)
.alpha(d1a.floatValue), .alpha(d2a.floatValue),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), 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), Box(
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,
modifier = Modifier modifier = Modifier
.padding(vertical = 10.dp) .width(buttonWidth)
.alpha(d2a.floatValue), .fillMaxHeight()
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), .offset { IntOffset(animatedOffset.roundToInt(), 0) }
) .zIndex(0f)
NoiseControlButton( .draggable(
icon = ImageBitmap.imageResource(R.drawable.adaptive), orientation = Orientation.Horizontal,
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, state = rememberDraggableState { delta ->
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, dragOffset = (dragOffset + delta).coerceIn(
backgroundColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) selectedBackground else Color.Transparent, 0f,
modifier = Modifier.weight(1f) with(density) { (buttonWidth * (buttonCount - 1)).toPx() }
) )
VerticalDivider( },
thickness = 1.dp, 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 modifier = Modifier
.padding(vertical = 10.dp) .fillMaxWidth()
.alpha(d3a.floatValue), .zIndex(1f)
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), ) {
) if (offListeningMode.value) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
backgroundColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) selectedBackground else Color.Transparent, modifier = Modifier.weight(1f),
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( // Labels row
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 8.dp) .fillMaxWidth()
.padding(top = 1.dp) .padding(horizontal = 8.dp)
) { .padding(top = 2.dp)
if (offListeningMode.value) { ) {
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(
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), style = TextStyle(fontSize = 12.sp, color = textColor),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f) 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 @Preview@Composable
@Composable
fun NoiseControlSettingsPreview() { fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService()) NoiseControlSettings(AirPodsService())
} }

View File

@@ -18,8 +18,10 @@
package me.kavishdevar.aln.composables 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -36,9 +38,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -55,6 +62,13 @@ import me.kavishdevar.aln.R
fun PressAndHoldSettings(navController: NavController) { fun PressAndHoldSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val 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<Color>(durationMillis = 500)
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
Text( Text(
text = stringResource(R.string.press_and_hold_airpods).uppercase(), 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) modifier = Modifier.padding(8.dp, bottom = 2.dp)
) )
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)) .background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(55.dp) .height(55.dp)
.background( .background(animatedLeftBackgroundColor, RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
backgroundColor, .pointerInput(Unit) {
RoundedCornerShape(14.dp) detectTapGestures(
) onPress = {
.clickable( leftBackgroundColor = dividerColor
onClick = { tryAwaitRelease()
navController.navigate("long_press/Left") leftBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
} },
), onTap = {
navController.navigate("long_press/Left")
}
)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Row( Row(
@@ -105,7 +120,6 @@ fun PressAndHoldSettings(navController: NavController) {
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
// TODO: Implement voice assistant on long press; for now, it's noise control
text = stringResource(R.string.noise_control), text = stringResource(R.string.noise_control),
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
@@ -128,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
} }
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
color = Color(0x40888888), color = dividerColor,
modifier = Modifier modifier = Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
) )
@@ -136,15 +150,19 @@ fun PressAndHoldSettings(navController: NavController) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(55.dp) .height(55.dp)
.background( .background(animatedRightBackgroundColor, RoundedCornerShape(bottomEnd = 14.dp, bottomStart = 14.dp))
backgroundColor, .pointerInput(Unit) {
RoundedCornerShape(18.dp) detectTapGestures(
) onPress = {
.clickable( rightBackgroundColor = dividerColor
onClick = { tryAwaitRelease()
navController.navigate("long_press/Right") rightBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
} },
), onTap = {
navController.navigate("long_press/Right")
}
)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Row( Row(
@@ -162,7 +180,6 @@ fun PressAndHoldSettings(navController: NavController) {
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
// TODO: Implement voice assistant on long press; for now, it's noise control
text = stringResource(R.string.noise_control), text = stringResource(R.string.noise_control),
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,

View File

@@ -21,19 +21,15 @@
package me.kavishdevar.aln.screens package me.kavishdevar.aln.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme 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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft 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.material.icons.filled.Send
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -62,7 +64,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -83,18 +85,36 @@ import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.flow.MutableStateFlow
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.utils.AirPodsNotifications
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun DebugScreen(navController: NavController) { fun DebugScreen(navController: NavController) {
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val text = remember { mutableStateListOf<String>("Log Start") }
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
val packetLogsFlow = remember { MutableStateFlow(emptySet<String>()) }
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
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( Scaffold(
topBar = { topBar = {
@@ -145,29 +165,6 @@ fun DebugScreen(navController: NavController) {
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7), else Color(0xFFF2F2F7),
) { paddingValues -> ) { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -181,44 +178,53 @@ fun DebugScreen(navController: NavController) {
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
content = { content = {
items(text.size) { index -> items(packetLogs.size) { index ->
val message = text[index] val message = packetLogs.elementAt(index)
val isSent = message.startsWith(">") val isSent = message.startsWith("Sent")
val backgroundColor = val isExpanded = expandedItems.value.contains(index)
if (isSent) Color(0xFFE1FFC7) else Color(0xFFD1D1D1)
if (message == "Log Start") { Card(
Spacer(modifier = Modifier.height(115.dp))
}
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(vertical = 2.dp, horizontal = 4.dp) // Reduced padding
.background(backgroundColor, RoundedCornerShape(12.dp)) .clickable {
.padding(12.dp), 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( Column(modifier = Modifier.padding(8.dp)) { // Reduced padding
modifier = Modifier.fillMaxWidth(), Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically, Icon(
horizontalArrangement = Arrangement.SpaceBetween imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
) { contentDescription = null,
if (!isSent) { tint = if (isSent) Color.Green else Color.Red,
Text("<", color = Color(0xFF00796B), fontSize = 16.sp) modifier = Modifier.size(24.dp) // Reduced icon size
}
Text(
text = if (isSent) message.substring(1) else message,
fontFamily = FontFamily(Font(R.font.hack)),
color = if (isSystemInDarkTheme()) Color(
0xFF000000
) )
else Color(0xFF000000), Spacer(modifier = Modifier.width(4.dp)) // Reduced spacing
modifier = Modifier.weight(1f) Column {
) Text(
text =
if (isSent) { if (isSent) message.substring(5).take(60) + (if (message.substring(5).length > 60) "..." else "")
Text(">", color = Color(0xFF00796B), fontSize = 16.sp) 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( IconButton(
onClick = { onClick = {
airPodsService.value?.sendPacket(packet.value.text) airPodsService.value?.sendPacket(packet.value.text)
text.add(packet.value.text)
packet.value = TextFieldValue("") packet.value = TextFieldValue("")
} }
) { ) {
@@ -282,23 +287,6 @@ fun DebugScreen(navController: NavController) {
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
val airPodsService = remember { mutableStateOf<AirPodsService?>(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)
} }
} }
} }

View File

@@ -41,16 +41,18 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale 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.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font 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 sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") } val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
Scaffold( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
@@ -117,17 +127,10 @@ fun RenameScreen(navController: NavController) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 8.dp) .padding(top = 8.dp)
) { ) {
var isFocused by remember { mutableStateOf(false) }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val cursorColor = if (isFocused) { // Show cursor only when focused val cursorColor = if (isDarkTheme) Color.White else Color.Black
if (isDarkTheme) Color.White else Color.Black
} else {
Color.Transparent
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -180,6 +183,7 @@ fun RenameScreen(navController: NavController) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp) .padding(start = 8.dp)
.focusRequester(focusRequester)
) )
} }
} }

View File

@@ -39,6 +39,7 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences
import android.media.AudioManager import android.media.AudioManager
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
@@ -95,6 +96,7 @@ object ServiceManager {
delay(1000) delay(1000)
context.startService(intent) context.startService(intent)
context.startActivity(Intent(context, MainActivity::class.java)) context.startActivity(Intent(context, MainActivity::class.java))
service?.clearLogs()
} }
} }
} }
@@ -106,6 +108,34 @@ class AirPodsService: Service() {
fun getService(): AirPodsService = this@AirPodsService 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<String> {
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 { override fun onBind(intent: Intent?): IBinder {
return LocalBinder() return LocalBinder()
} }
@@ -133,7 +163,9 @@ class AirPodsService: Service() {
} }
} }
private fun forwardPacket(packet: String, outputStream: OutputStream) { private fun forwardPacket(packet: String, outputStream: OutputStream) {
outputStream.write(packet.toByteArray()) val byteArray = packet.toByteArray()
outputStream.write(byteArray)
logPacket(byteArray, "Sent")
} }
private fun connectToAirPods() { private fun connectToAirPods() {
@@ -400,7 +432,7 @@ class AirPodsService: Service() {
.putString("name", name).apply() .putString("name", name).apply()
} }
Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString()) Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) { if (!CrossDevice.checkAirPodsConnectionStatus()) {
Log.d("AirPodsService", "$name connected") Log.d("AirPodsService", "$name connected")
showPopup(this@AirPodsService, name.toString()) showPopup(this@AirPodsService, name.toString())
connectToSocket(device!!) connectToSocket(device!!)
@@ -463,7 +495,7 @@ class AirPodsService: Service() {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) { if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) { if (!CrossDevice.checkAirPodsConnectionStatus()) {
connectToSocket(device) connectToSocket(device)
} }
this@AirPodsService.sendBroadcast( 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 return START_STICKY
} }
@@ -581,6 +617,7 @@ class AirPodsService: Service() {
var data: ByteArray = byteArrayOf() var data: ByteArray = byteArrayOf()
if (bytesRead > 0) { if (bytesRead > 0) {
data = buffer.copyOfRange(0, bytesRead) data = buffer.copyOfRange(0, bytesRead)
logPacket(data, "AirPods")
sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.Companion.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead)) putExtra("data", buffer.copyOfRange(0, bytesRead))
}) })
@@ -824,8 +861,10 @@ class AirPodsService: Service() {
return return
} }
if (this::socket.isInitialized) { if (this::socket.isInitialized) {
socket.outputStream?.write(fromHex.toByteArray()) val byteArray = fromHex.toByteArray()
socket.outputStream?.write(byteArray)
socket.outputStream?.flush() socket.outputStream?.flush()
logPacket(byteArray, "Sent")
} }
} }
@@ -837,6 +876,7 @@ class AirPodsService: Service() {
if (this::socket.isInitialized) { if (this::socket.isInitialized) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
logPacket(packet, "Sent")
} }
} }
@@ -1015,7 +1055,6 @@ class AirPodsService: Service() {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01, val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01,
nameBytes.size.toByte(), 0x00) + nameBytes nameBytes.size.toByte(), 0x00) + nameBytes
sendPacket(bytes) sendPacket(bytes)
socket.outputStream?.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) } val hex = bytes.joinToString(" ") { "%02X".format(it) }
updateNotificationContent(true, name, batteryNotification.getBattery()) updateNotificationContent(true, name, batteryNotification.getBattery())
Log.d("AirPodsService", "setName: $name, sent packet: $hex") 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 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() var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
sendPacket(bytes) 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" 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() bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
sendPacket(bytes) sendPacket(bytes)
socket.outputStream?.flush()
} }
fun setLoudSoundReduction(enabled: Boolean) { fun setLoudSoundReduction(enabled: Boolean) {
val hex = "52 1B 00 0${if (enabled) "1" else "0"}" val hex = "52 1B 00 0${if (enabled) "1" else "0"}"
val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
sendPacket(bytes) sendPacket(bytes)
socket.outputStream?.flush()
} }
fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int { fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int {
for (i in oldArray.indices) { for (i in oldArray.indices) {
@@ -1175,11 +1211,12 @@ class AirPodsService: Service() {
} }
packet?.let { packet?.let {
Log.d("AirPodsService", "Sending packet: ${it.joinToString(" ") { "%02X".format(it) }}") Log.d("AirPodsService", "Sending packet: ${it.joinToString(" ") { "%02X".format(it) }}")
socket.outputStream.write(it) sendPacket(it)
} }
} }
override fun onDestroy() { override fun onDestroy() {
clearPacketLogs()
Log.d("AirPodsService", "Service stopped is being destroyed for some reason!") Log.d("AirPodsService", "Service stopped is being destroyed for some reason!")
try { try {
unregisterReceiver(bluetoothReceiver) unregisterReceiver(bluetoothReceiver)

View File

@@ -5,8 +5,13 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -21,26 +26,36 @@ enum class CrossDevicePackets(val packet: ByteArray) {
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)), REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)), REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)), REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)), AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
} }
object CrossDevice { object CrossDevice {
var initialized = false
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342") private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
private var serverSocket: BluetoothServerSocket? = null private var serverSocket: BluetoothServerSocket? = null
private var clientSocket: BluetoothSocket? = null private var clientSocket: BluetoothSocket? = null
private lateinit var bluetoothAdapter: BluetoothAdapter 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 isAvailable: Boolean = false // set to true when airpods are connected to another device
var batteryBytes: ByteArray = byteArrayOf() var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf() var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
private const val packetLogKey = "packet_log"
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun init(context: Context) { fun init(context: Context) {
Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice") 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() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startAdvertising()
startServer() startServer()
initialized = true
} }
@SuppressLint("MissingPermission") @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) { fun setAirPodsConnected(connected: Boolean) {
if (connected) { if (connected) {
isAvailable = false isAvailable = false
@@ -71,9 +114,21 @@ object CrossDevice {
fun sendReceivedPacket(packet: ByteArray) { fun sendReceivedPacket(packet: ByteArray) {
Log.d("AirPodsQuickSwitchService", "Sending packet to remote device") 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) 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") @SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) { private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("AirPodsQuickSwitchService", "Client connected") Log.d("AirPodsQuickSwitchService", "Client connected")
@@ -85,6 +140,7 @@ object CrossDevice {
while (true) { while (true) {
bytes = inputStream.read(buffer) bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes) val packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}") Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) { if (bytes == -1) {
break break
@@ -102,19 +158,17 @@ object CrossDevice {
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received ANC request") Log.d("AirPodsQuickSwitchService", "Received ANC request")
sendRemotePacket(ancBytes) 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 { else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
// the AIRPODS_CONNECTED wasn't sent before
isAvailable = true isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
val trimmedPacket = val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
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", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket)}")
Log.d( Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
"AirPodsQuickSwitchService",
"Relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}"
)
if (ServiceManager.getService()?.isConnectedLocally == true) { if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) } val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex) ServiceManager.getService()?.sendPacket(packetInHex)
@@ -142,6 +196,25 @@ object CrossDevice {
} }
clientSocket?.outputStream?.write(byteArray) clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush() clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
Log.d("AirPodsQuickSwitchService", "Sent packet to remote device") 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
}
}
} }

View File

@@ -157,8 +157,13 @@ class AirPodsNotifications {
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean { 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) { 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 return false
} }
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString()) Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())

View File

@@ -1,6 +1,6 @@
[versions] [versions]
accompanistPermissions = "0.36.0" accompanistPermissions = "0.36.0"
agp = "8.7.3" agp = "8.8.0"
hiddenapibypass = "4.3" hiddenapibypass = "4.3"
kotlin = "2.0.0" kotlin = "2.0.0"
coreKtx = "1.15.0" coreKtx = "1.15.0"

View File

@@ -39,6 +39,10 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0" #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 { class AirPodsTrayApp : public QObject {
Q_OBJECT Q_OBJECT
@@ -88,6 +92,8 @@ public:
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated); connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
discoveryAgent = new QBluetoothDeviceDiscoveryAgent(); discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
discoveryAgent->start(); discoveryAgent->start();
@@ -107,6 +113,16 @@ public:
} }
initializeMprisInterface(); initializeMprisInterface();
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived); 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<QVariant> 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: public slots:
@@ -288,6 +304,12 @@ public slots:
} }
void onDeviceDiscovered(const QBluetoothDeviceInfo &device) { 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() << ")"); LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
LOG_DEBUG("Found AirPods device" + device.name()); LOG_DEBUG("Found AirPods device" + device.name());
@@ -297,6 +319,8 @@ public slots:
void onDiscoveryFinished() { void onDiscoveryFinished() {
LOG_INFO("Device discovery finished"); LOG_INFO("Device discovery finished");
// Restart discovery to continuously listen for broadcasts
discoveryAgent->start();
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices(); const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
for (const QBluetoothDeviceInfo &device : discoveredDevices) { for (const QBluetoothDeviceInfo &device : discoveredDevices) {
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
@@ -335,57 +359,76 @@ public slots:
return; return;
} }
LOG_INFO("Connecting to device: " << device.name() << " (" << device.address().toString() << ")"); LOG_INFO("Checking connection status with phone before connecting to device: " << device.name());
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); QByteArray connectionStatusRequest = QByteArray::fromHex("00020003");
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() { if (phoneSocket && phoneSocket->isOpen()) {
LOG_INFO("Connected to device, sending initial packets"); phoneSocket->write(connectionStatusRequest);
discoveryAgent->stop(); 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);
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000"); QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000"); connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff"); LOG_INFO("Connected to device, sending initial packets");
discoveryAgent->stop();
qint64 bytesWritten = localSocket->write(handshakePacket); QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten); QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001"); qint64 bytesWritten = localSocket->write(handshakePacket);
phoneSocket->write(airpodsConnectedPacket); LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) { QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
LOG_INFO("Bytes written: " << bytes); phoneSocket->write(airpodsConnectedPacket);
if (bytes > 0) { LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
static int step = 0;
switch (step) { connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
case 0: LOG_INFO("Bytes written: " << bytes);
localSocket->write(setSpecificFeaturesPacket); if (bytes > 0) {
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex()); static int step = 0;
step++; switch (step) {
break; case 0:
case 1: localSocket->write(setSpecificFeaturesPacket);
localSocket->write(requestNotificationsPacket); LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex()); step++;
step++; break;
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<QBluetoothSocket::SocketError>::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 {
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() { LOG_ERROR("Phone socket is not open, cannot send connection status request");
QByteArray data = localSocket->readAll(); }
LOG_DEBUG("Data received: " << data.toHex());
parseData(data);
relayPacketToPhone(data);
});
});
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::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(":", "_");
} }
void parseData(const QByteArray &data) { void parseData(const QByteArray &data) {
@@ -416,7 +459,6 @@ public slots:
.arg(caseLevel); .arg(caseLevel);
LOG_INFO("Battery status: " << batteryStatus); LOG_INFO("Battery status: " << batteryStatus);
emit batteryStatusChanged(batteryStatus); emit batteryStatusChanged(batteryStatus);
relayPacketToPhone(data);
} else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) { } else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) {
LOG_INFO("Received conversational awareness data"); LOG_INFO("Received conversational awareness data");
@@ -436,9 +478,6 @@ public slots:
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@"); process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
process.waitForFinished(); process.waitForFinished();
QString output = process.readAllStandardOutput(); 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+)%"); QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%");
QRegularExpressionMatch match = re.match(output); QRegularExpressionMatch match = re.match(output);
if (match.hasMatch()) { if (match.hasMatch()) {
@@ -513,6 +552,7 @@ public slots:
phoneSocket->write(header + packet); phoneSocket->write(header + packet);
LOG_DEBUG("Relayed packet to phone with header: " << (header + packet).toHex()); LOG_DEBUG("Relayed packet to phone with header: " << (header + packet).toHex());
} else { } else {
connectToPhone();
LOG_WARN("Phone socket is not open, cannot relay packet"); LOG_WARN("Phone socket is not open, cannot relay packet");
} }
} }
@@ -532,6 +572,11 @@ public slots:
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) { } else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
LOG_INFO("AirPods disconnected"); LOG_INFO("AirPods disconnected");
// Handle 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 { } else {
if (socket && socket->isOpen()) { if (socket && socket->isOpen()) {
socket->write(packet); socket->write(packet);

Binary file not shown.

View File

@@ -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