From 5d364a662c516d88eb8a4f62f288603e64c7b70b Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Sat, 25 Jan 2025 03:12:23 +0530 Subject: [PATCH] move files across computers --- .../java/me/kavishdevar/aln/MainActivity.kt | 135 +++++++++++---- .../aln/composables/AccessibilitySettings.kt | 2 +- .../aln/composables/IndependentToggle.kt | 37 ++-- .../kavishdevar/aln/composables/NameField.kt | 31 +++- .../aln/composables/NavigationButton.kt | 36 ++-- .../aln/composables/NoiseControlSettings.kt | 19 +-- .../aln/screens/AirPodsSettingsScreen.kt | 2 +- .../me/kavishdevar/aln/screens/DebugScreen.kt | 63 ++----- .../aln/screens/PressAndHoldSettingsScreen.kt | 36 +++- .../kavishdevar/aln/screens/RenameScreen.kt | 14 +- .../aln/services/AirPodsService.kt | 113 ++++++------ .../src/main/res/drawable/blur_background.xml | 7 + .../res/drawable/circular_progress_bar.xml | 15 ++ .../src/main/res/drawable/ring_background.xml | 10 ++ .../main/res/layout-v31/battery_widget.xml | 150 +++++++++------- .../src/main/res/layout/battery_widget.xml | 161 ++++++++++++------ .../main/res/xml-v31/battery_widget_info.xml | 14 -- .../src/main/res/xml/battery_widget_info.xml | 2 +- linux/AirPodsTrayApp.h | 53 ++++++ linux/BluetoothHandler.cpp | 109 ++++++++++++ linux/BluetoothHandler.h | 23 +++ linux/CMakeLists.txt | 3 + 22 files changed, 714 insertions(+), 321 deletions(-) create mode 100644 android/app/src/main/res/drawable/blur_background.xml create mode 100644 android/app/src/main/res/drawable/circular_progress_bar.xml create mode 100644 android/app/src/main/res/drawable/ring_background.xml delete mode 100644 android/app/src/main/res/xml-v31/battery_widget_info.xml create mode 100644 linux/AirPodsTrayApp.h create mode 100644 linux/BluetoothHandler.cpp create mode 100644 linux/BluetoothHandler.h diff --git a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt index e9a816c..d02fb57 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -35,10 +35,17 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -48,6 +55,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -183,43 +191,104 @@ fun Main() { context.registerReceiver(connectionStatusReceiver, filter) } Log.d("MainActivity", "Registered Receiver") - - NavHost( - navController = navController, - startDestination = "settings", - enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) }, - exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) }, - popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) }, - popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) } + Box ( + modifier = Modifier + .padding(0.dp) + .fillMaxSize() + .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) ) { - composable("settings") { - if (airPodsService.value != null) { - AirPodsSettingsScreen( - dev = airPodsService.value?.device, - service = airPodsService.value!!, - navController = navController, - isConnected = isConnected.value, - isRemotelyConnected = isRemotelyConnected.value + NavHost( + navController = navController, + startDestination = "settings", + enterTransition = { + slideInHorizontally( + initialOffsetX = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + scaleIn( + initialScale = 0.85f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + }, + exitTransition = { + slideOutHorizontally( + targetOffsetX = { -it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + scaleOut( + targetScale = 0.85f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + }, + popEnterTransition = { + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + scaleIn( + initialScale = 0.85f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + }, + popExitTransition = { + slideOutHorizontally( + targetOffsetX = { it }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) + ) + scaleOut( + targetScale = 0.85f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessLow + ) ) } - } - composable("debug") { - DebugScreen(navController = navController) - } - composable("long_press/{bud}") { navBackStackEntry -> - LongPress( - navController = navController, - name = navBackStackEntry.arguments?.getString("bud")!! - ) - } - composable("rename") { navBackStackEntry -> - RenameScreen(navController) - } - composable("app_settings") { - AppSettingsScreen(navController) + ) { + composable("settings") { + if (airPodsService.value != null) { + AirPodsSettingsScreen( + dev = airPodsService.value?.device, + service = airPodsService.value!!, + navController = navController, + isConnected = isConnected.value, + isRemotelyConnected = isRemotelyConnected.value + ) + } + } + composable("debug") { + DebugScreen(navController = navController) + } + composable("long_press/{bud}") { navBackStackEntry -> + LongPress( + navController = navController, + name = navBackStackEntry.arguments?.getString("bud")!! + ) + } + composable("rename") { navBackStackEntry -> + RenameScreen(navController) + } + composable("app_settings") { + AppSettingsScreen(navController) + } } } - serviceConnection = remember { object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt index b2dfd6b..bd62fe7 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/AccessibilitySettings.kt @@ -131,7 +131,7 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences) VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences) - TransparencySettings(service = service, sharedPreferences = sharedPreferences) +// TransparencySettings(service = service, sharedPreferences = sharedPreferences) } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt index 1c5b21c..b3a7f7f 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/IndependentToggle.kt @@ -19,8 +19,10 @@ package me.kavishdevar.aln.composables import android.content.SharedPreferences +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.Row @@ -38,6 +40,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -51,6 +54,8 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase() var checked by remember { mutableStateOf(default) } + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) LaunchedEffect(sharedPreferences) { checked = sharedPreferences.getBoolean(snakeCasedName, true) @@ -58,19 +63,25 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin Box ( modifier = Modifier .padding(vertical = 8.dp) - .background( - if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), - RoundedCornerShape(14.dp) - ) - .clickable { - checked = !checked - sharedPreferences - .edit() - .putBoolean(snakeCasedName, checked) - .apply() + .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + checked = !checked + sharedPreferences + .edit() + .putBoolean(snakeCasedName, checked) + .apply() - val method = service::class.java.getMethod(functionName, Boolean::class.java) - method.invoke(service, checked) + val method = service::class.java.getMethod(functionName, Boolean::class.java) + method.invoke(service, checked) + } + ) }, ) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt index 1bfc321..8b4f6c7 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/NameField.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.Arrangement import androidx.compose.foundation.layout.Box @@ -44,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -62,9 +65,11 @@ fun NameField( val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + val textColor = if (isDarkTheme) Color.White else Color.Black - val cursorColor = if (isFocused) { // Show cursor only when focused + val cursorColor = if (isFocused) { if (isDarkTheme) Color.White else Color.Black } else { Color.Transparent @@ -72,11 +77,19 @@ fun NameField( Box ( modifier = Modifier - .clickable( - onClick = { - navController.navigate("rename") - } - ) + .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + navController.navigate("rename") + } + ) + } ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -84,7 +97,7 @@ fun NameField( .fillMaxWidth() .height(55.dp) .background( - backgroundColor, + animatedBackgroundColor, RoundedCornerShape(14.dp) ) .padding(horizontal = 16.dp, vertical = 8.dp) diff --git a/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt index 2376336..2f74e89 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/composables/NavigationButton.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.Row import androidx.compose.foundation.layout.Spacer @@ -34,8 +36,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults 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.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -44,29 +51,38 @@ import androidx.navigation.NavController @Composable fun NavigationButton(to: String, name: String, navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + Row( modifier = Modifier - .background( - if (isSystemInDarkTheme()) Color( - 0xFF1C1C1E - ) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp) - ) + .background(animatedBackgroundColor, RoundedCornerShape(14.dp)) .height(55.dp) - .clickable { - navController.navigate(to) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { + navController.navigate(to) + } + ) } ) { Text( text = name, modifier = Modifier.padding(16.dp), - color = if (isSystemInDarkTheme()) Color.White else Color.Black + color = if (isDarkTheme) Color.White else Color.Black ) Spacer(modifier = Modifier.weight(1f)) IconButton( onClick = { navController.navigate(to) }, colors = IconButtonDefaults.iconButtonColors( containerColor = Color.Transparent, - contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black + contentColor = if (isDarkTheme) Color.White else Color.Black ), modifier = Modifier .padding(start = 16.dp) 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 c5e08a2..f962537 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 @@ -170,7 +170,7 @@ fun NoiseControlSettings(service: AirPodsService) { context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) } - Text(// all caps + Text( text = stringResource(R.string.noise_control).uppercase(), style = TextStyle( fontSize = 14.sp, @@ -182,7 +182,7 @@ fun NoiseControlSettings(service: AirPodsService) { BoxWithConstraints( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp) // Adjusted padding + .padding(vertical = 8.dp) ) { val density = LocalDensity.current val buttonCount = if (offListeningMode.value) 4 else 3 @@ -229,10 +229,9 @@ fun NoiseControlSettings(service: AirPodsService) { Box( modifier = Modifier .fillMaxWidth() - .height(60.dp) // Adjusted height + .height(60.dp) .background(backgroundColor, RoundedCornerShape(14.dp)) ) { - // First: Background Row (just for visual) Row( modifier = Modifier.fillMaxWidth() ) { @@ -327,7 +326,6 @@ fun NoiseControlSettings(service: AirPodsService) { ) } - // Button row (top layer) Row( modifier = Modifier .fillMaxWidth() @@ -387,12 +385,11 @@ fun NoiseControlSettings(service: AirPodsService) { } } - // Labels row Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 2.dp) + .padding(horizontal = 4.dp) + .padding(top = 4.dp) ) { if (offListeningMode.value) { Text( @@ -429,8 +426,8 @@ fun NoiseControlSettings(service: AirPodsService) { } } -@Preview@Composable +@Preview() +@Composable fun NoiseControlSettingsPreview() { NoiseControlSettings(AirPodsService()) -} - +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt index 34598a9..8586ab2 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/screens/AirPodsSettingsScreen.kt @@ -283,7 +283,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, ) Spacer(Modifier.height(24.dp)) Text( - text = "Please connect your AirPods to access settings. If you're stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)", + text = "Please connect your AirPods to access settings.", style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Light, 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 67ec73e..cbb6e7a 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,12 +21,7 @@ package me.kavishdevar.aln.screens import android.annotation.SuppressLint -import android.content.ComponentName import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -65,6 +60,8 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -87,7 +84,7 @@ 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.services.ServiceManager @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @@ -96,24 +93,13 @@ fun DebugScreen(navController: NavController) { val hazeState = remember { HazeState() } val context = LocalContext.current val listState = rememberLazyListState() + val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } } 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) + LaunchedEffect(Unit) { + ServiceManager.getService()?.packetLogsFlow?.collect { packetLogsFlow.value = it } } - val packetLogs = packetLogsFlow.collectAsState(setOf()).value Scaffold( @@ -150,7 +136,7 @@ fun DebugScreen(navController: NavController) { state = hazeState, style = CupertinoMaterials.thick(), block = { - alpha = if (listState.firstVisibleItemIndex > 0) { + alpha = if (scrollOffset > 0) { 1f } else { 0f @@ -170,7 +156,7 @@ fun DebugScreen(navController: NavController) { .fillMaxSize() .imePadding() .haze(hazeState) - .padding(top = 0.dp) + .padding(top = paddingValues.calculateTopPadding()) ) { LazyColumn( state = listState, @@ -186,7 +172,7 @@ fun DebugScreen(navController: NavController) { Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 2.dp, horizontal = 4.dp) // Reduced padding + .padding(vertical = 2.dp, horizontal = 4.dp) .clickable { expandedItems.value = if (isExpanded) { expandedItems.value - index @@ -194,21 +180,21 @@ fun DebugScreen(navController: NavController) { expandedItems.value + index } }, - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), // Reduced elevation - shape = RoundedCornerShape(4.dp), // Reduced corner radius + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(4.dp), colors = CardDefaults.cardColors( containerColor = Color.Transparent ) ) { - Column(modifier = Modifier.padding(8.dp)) { // Reduced padding + Column(modifier = Modifier.padding(8.dp)) { 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 + modifier = Modifier.size(24.dp) ) - Spacer(modifier = Modifier.width(4.dp)) // Reduced spacing + Spacer(modifier = Modifier.width(4.dp)) Column { Text( text = @@ -217,7 +203,7 @@ fun DebugScreen(navController: NavController) { style = MaterialTheme.typography.bodySmall, ) if (isExpanded) { - Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing + Spacer(modifier = Modifier.height(4.dp)) Text( text = message.substring(if (isSent) 5 else 9), style = MaterialTheme.typography.bodySmall, @@ -232,22 +218,7 @@ fun DebugScreen(navController: NavController) { } ) Spacer(modifier = Modifier.height(8.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) + val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) } HorizontalDivider() Row( modifier = Modifier @@ -267,7 +238,7 @@ fun DebugScreen(navController: NavController) { trailingIcon = { IconButton( onClick = { - airPodsService.value?.sendPacket(packet.value.text) + airPodsService?.value?.sendPacket(packet.value.text) packet.value = TextFieldValue("") } ) { diff --git a/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt index 8b993f8..fb83f3a 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/screens/PressAndHoldSettingsScreen.kt @@ -20,8 +20,10 @@ package me.kavishdevar.aln.screens import android.content.Context import android.util.Log +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.Arrangement import androidx.compose.foundation.layout.Column @@ -46,13 +48,16 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.imageResource import androidx.compose.ui.text.TextStyle @@ -155,13 +160,13 @@ fun LongPress(navController: NavController, name: String) { horizontalAlignment = Alignment.CenterHorizontally ) { val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false) - LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation) + LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true) if (offListeningMode) RightDivider() - LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency) + LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode) RightDivider() LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive) RightDivider() - LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation) + LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true) } Text( "Press and hold the stem to cycle between the selected noise control modes.", @@ -176,7 +181,7 @@ fun LongPress(navController: NavController, name: String) { } @Composable -fun LongPressElement (name: String, checked: MutableState, id: String, enabled: Boolean = true, resourceId: Int) { +fun LongPressElement(name: String, checked: MutableState, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false) @@ -213,15 +218,30 @@ fun LongPressElement (name: String, checked: MutableState, id: String, ServiceManager.getService() ?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode) } + val shape = when { + isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) + isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) + else -> RoundedCornerShape(0.dp) + } + var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) if (!enabled) { valueChanged(false) } else { Row( modifier = Modifier .height(72.dp) - .clickable( - onClick = { valueChanged() } - ) + .background(animatedBackgroundColor, shape) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + }, + onTap = { valueChanged() } + ) + } .padding(horizontal = 16.dp, vertical = 0.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween 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 7d1e371..def94bf 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 @@ -54,10 +54,12 @@ 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.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -65,18 +67,20 @@ import androidx.navigation.NavController import me.kavishdevar.aln.R import me.kavishdevar.aln.services.ServiceManager + @OptIn(ExperimentalMaterial3Api::class) @Composable fun RenameScreen(navController: NavController) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val isDarkTheme = isSystemInDarkTheme() - val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") } + val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) } val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect(Unit) { focusRequester.requestFocus() keyboardController?.show() + name.value = name.value.copy(selection = TextRange(name.value.text.length)) } Scaffold( @@ -102,7 +106,7 @@ fun RenameScreen(navController: NavController) { modifier = Modifier.scale(1.5f) ) Text( - text = name.value, + text = name.value.text, style = TextStyle( fontSize = 18.sp, fontWeight = FontWeight.Medium, @@ -146,8 +150,8 @@ fun RenameScreen(navController: NavController) { value = name.value, onValueChange = { name.value = it - sharedPreferences.edit().putString("name", it).apply() - ServiceManager.getService()?.setName(it) + sharedPreferences.edit().putString("name", it.text).apply() + ServiceManager.getService()?.setName(it.text) }, textStyle = TextStyle( color = textColor, @@ -167,7 +171,7 @@ fun RenameScreen(navController: NavController) { } IconButton( onClick = { - name.value = "" + name.value = TextFieldValue("") sharedPreferences.edit().putString("name", "").apply() ServiceManager.getService()?.setName("") } 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 b9c1b2b..eb6e816 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 @@ -23,6 +23,7 @@ import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.appwidget.AppWidgetManager import android.bluetooth.BluetoothAdapter @@ -57,6 +58,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch import me.kavishdevar.aln.BatteryWidget @@ -73,7 +76,6 @@ import me.kavishdevar.aln.utils.LongPressPackets import me.kavishdevar.aln.utils.MediaController import me.kavishdevar.aln.utils.Window import org.lsposed.hiddenapibypass.HiddenApiBypass -import java.io.OutputStream object ServiceManager { private var service: AirPodsService? = null @@ -110,10 +112,13 @@ class AirPodsService: Service() { private lateinit var sharedPreferences: SharedPreferences private val packetLogKey = "packet_log" + private val _packetLogsFlow = MutableStateFlow>(emptySet()) + val packetLogsFlow: StateFlow> get() = _packetLogsFlow + override fun onCreate() { super.onCreate() - sharedPreferences = getSharedPreferences("packet_logs", Context.MODE_PRIVATE) + sharedPreferences = getSharedPreferences("packet_logs", MODE_PRIVATE) } private fun logPacket(packet: ByteArray, source: String) { @@ -121,6 +126,7 @@ class AirPodsService: Service() { val logEntry = "$source: $packetHex" val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf() logs.add(logEntry) + _packetLogsFlow.value = logs sharedPreferences.edit().putStringSet(packetLogKey, logs).apply() } @@ -134,6 +140,7 @@ class AirPodsService: Service() { fun clearLogs() { clearPacketLogs() // Expose a method to clear logs + _packetLogsFlow.value = emptySet() } override fun onBind(intent: Intent?): IBinder { @@ -151,32 +158,6 @@ class AirPodsService: Service() { popupShown = true } - private fun handleMessage(message: String, outputStream: OutputStream) { - when (message) { - "PAUSE_MEDIA" -> MediaController.sendPause() - "PLAY_MEDIA" -> MediaController.sendPlay() - "CONNECT_AIRPODS" -> connectToAirPods() - "DISCONNECT_AIRPODS" -> disconnectFromAirPods() - else -> { - forwardPacket(message, outputStream) - } - } - } - private fun forwardPacket(packet: String, outputStream: OutputStream) { - val byteArray = packet.toByteArray() - outputStream.write(byteArray) - logPacket(byteArray, "Sent") - } - - private fun connectToAirPods() { - Log.d("AirPodsQuickSwitchService", "Should connect to AirPods now.") - } - - private fun disconnectFromAirPods() { - Log.d("AirPodsQuickSwitchService", "Should disconnect from AirPods now.") - } - - @Suppress("ClassName") private object bluetoothReceiver: BroadcastReceiver() { @SuppressLint("MissingPermission") @@ -254,6 +235,7 @@ class AirPodsService: Service() { } + @OptIn(ExperimentalMaterial3Api::class) fun startForegroundNotification() { val notificationChannel = NotificationChannel( "background_service_status", @@ -262,9 +244,17 @@ class AirPodsService: Service() { ) val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(notificationChannel) + + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notification = NotificationCompat.Builder(this, "background_service_status") .setSmallIcon(R.drawable.airpods) .setContentTitle("AirPods not connected") + .setContentText("Tap to open app") + .setContentIntent(pendingIntent) .setCategory(Notification.CATEGORY_SERVICE) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) @@ -272,8 +262,7 @@ class AirPodsService: Service() { try { startForeground(1, notification) - } - catch (e: Exception) { + } catch (e: Exception) { e.printStackTrace() } } @@ -309,73 +298,88 @@ class AirPodsService: Service() { it.setTextViewText( R.id.left_battery_widget, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT }?.let { -// if (it.status != BatteryStatus.DISCONNECTED) { - "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" -// } else { -// "" -// } + "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } ?: "" ) + it.setProgressBar( + R.id.left_battery_progress, + 100, + batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT }?.level ?: 0, + false + ) it.setTextViewText( R.id.right_battery_widget, batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT }?.let { -// if (it.status != BatteryStatus.DISCONNECTED) { - "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" -// } else { -// "" -// } + "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } ?: "" ) + it.setProgressBar( + R.id.right_battery_progress, + 100, + batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT }?.level ?: 0, + false + ) it.setTextViewText( R.id.case_battery_widget, batteryNotification.getBattery().find { it.component == BatteryComponent.CASE }?.let { -// if (it.status != BatteryStatus.DISCONNECTED) { - "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" -// } else { -// "" -// } + "${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } ?: "" ) + it.setProgressBar( + R.id.case_battery_progress, + 100, + batteryNotification.getBattery().find { it.component == BatteryComponent.CASE }?.level ?: 0, + false + ) } Log.d("AirPodsService", "Updating battery widget") appWidgetManager.updateAppWidget(widgetIds, remoteViews) } + @OptIn(ExperimentalMaterial3Api::class) fun updateNotificationContent(connected: Boolean, airpodsName: String? = null, batteryList: List? = null) { val notificationManager = getSystemService(NotificationManager::class.java) var updatedNotification: Notification? = null + val notificationIntent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + if (connected) { updatedNotification = NotificationCompat.Builder(this, "background_service_status") .setSmallIcon(R.drawable.airpods) - .setContentTitle("""$airpodsName –${batteryList?.find { it.component == BatteryComponent.LEFT }?.let { + .setContentTitle(airpodsName) + .setContentText("""${batteryList?.find { it.component == BatteryComponent.LEFT }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - " L:${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" + "L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } else { "" } - } ?: ""}${batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { + } ?: ""} ${batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - " R:${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" + "R: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } else { "" } - } ?: ""}${batteryList?.find { it.component == BatteryComponent.CASE }?.let { + } ?: ""} ${batteryList?.find { it.component == BatteryComponent.CASE }?.let { if (it.status != BatteryStatus.DISCONNECTED) { - " C:${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" + "Case: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" } else { "" } } ?: ""}""") + .setContentIntent(pendingIntent) .setCategory(Notification.CATEGORY_SERVICE) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .build() - } else { updatedNotification = NotificationCompat.Builder(this, "background_service_status") .setSmallIcon(R.drawable.airpods) .setContentTitle("AirPods not connected") + .setContentText("Tap to open app") + .setContentIntent(pendingIntent) .setCategory(Notification.CATEGORY_SERVICE) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) @@ -952,11 +956,6 @@ class AirPodsService: Service() { var earDetectionEnabled = true - fun setCaseChargingSounds(enabled: Boolean) { - val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01) - sendPacket(bytes) - } - fun setEarDetection(enabled: Boolean) { earDetectionEnabled = enabled } diff --git a/android/app/src/main/res/drawable/blur_background.xml b/android/app/src/main/res/drawable/blur_background.xml new file mode 100644 index 0000000..31f0cf3 --- /dev/null +++ b/android/app/src/main/res/drawable/blur_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/circular_progress_bar.xml b/android/app/src/main/res/drawable/circular_progress_bar.xml new file mode 100644 index 0000000..420cf39 --- /dev/null +++ b/android/app/src/main/res/drawable/circular_progress_bar.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ring_background.xml b/android/app/src/main/res/drawable/ring_background.xml new file mode 100644 index 0000000..26ff600 --- /dev/null +++ b/android/app/src/main/res/drawable/ring_background.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/android/app/src/main/res/layout-v31/battery_widget.xml b/android/app/src/main/res/layout-v31/battery_widget.xml index 89d90d7..c7c13b1 100644 --- a/android/app/src/main/res/layout-v31/battery_widget.xml +++ b/android/app/src/main/res/layout-v31/battery_widget.xml @@ -4,70 +4,104 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/battery_widget" - android:theme="@style/Theme.ALN.AppWidgetContainer"> + android:theme="@style/Theme.ALN.AppWidgetContainer" + android:background="@drawable/blur_background"> + + android:gravity="center" + android:orientation="horizontal"> - + + + + + + - - - + + + + + + + - - + android:gravity="center" + android:orientation="vertical"> + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml index 0717a8e..3494165 100644 --- a/android/app/src/main/res/layout/battery_widget.xml +++ b/android/app/src/main/res/layout/battery_widget.xml @@ -4,72 +4,125 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:id="@+id/battery_widget" - android:theme="@style/Theme.ALN.AppWidgetContainer"> + android:theme="@style/Theme.ALN.AppWidgetContainer" + android:background="@drawable/blur_background"> + - + android:gravity="center" + android:orientation="horizontal"> - - - + + + + + + + + - - + + + + + + + + + android:gravity="center" + android:orientation="vertical"> + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml-v31/battery_widget_info.xml b/android/app/src/main/res/xml-v31/battery_widget_info.xml deleted file mode 100644 index af4f4a5..0000000 --- a/android/app/src/main/res/xml-v31/battery_widget_info.xml +++ /dev/null @@ -1,14 +0,0 @@ - - \ No newline at end of file diff --git a/android/app/src/main/res/xml/battery_widget_info.xml b/android/app/src/main/res/xml/battery_widget_info.xml index af4f4a5..7fcdc15 100644 --- a/android/app/src/main/res/xml/battery_widget_info.xml +++ b/android/app/src/main/res/xml/battery_widget_info.xml @@ -10,5 +10,5 @@ android:resizeMode="horizontal|vertical" android:targetCellWidth="1" android:targetCellHeight="1" - android:updatePeriodMillis="86400000" + android:updatePeriodMillis="300000" android:widgetCategory="home_screen|keyguard" /> \ No newline at end of file diff --git a/linux/AirPodsTrayApp.h b/linux/AirPodsTrayApp.h new file mode 100644 index 0000000..92afff8 --- /dev/null +++ b/linux/AirPodsTrayApp.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "BluetoothHandler.h" + +class AirPodsTrayApp : public QObject { + Q_OBJECT + +public: + AirPodsTrayApp(); + +public slots: + void connectToDevice(const QString &address); + void showAvailableDevices(); + void setNoiseControlMode(int mode); + void setConversationalAwareness(bool enabled); + void updateNoiseControlMenu(int mode); + void updateBatteryTooltip(const QString &status); + void updateTrayIcon(const QString &status); + void handleEarDetection(const QString &status); + +private slots: + void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason); + void onDeviceDiscovered(const QBluetoothDeviceInfo &device); + void onDiscoveryFinished(); + void onDeviceConnected(const QBluetoothAddress &address); + void onDeviceDisconnected(const QBluetoothAddress &address); + void onPhoneDataReceived(); + +signals: + void noiseControlModeChanged(int mode); + void earDetectionStatusChanged(const QString &status); + void batteryStatusChanged(const QString &status); + +private: + void initializeMprisInterface(); + void connectToPhone(); + void relayPacketToPhone(const QByteArray &packet); + void handlePhonePacket(const QByteArray &packet); + + QSystemTrayIcon *trayIcon; + QMenu *trayMenu; + QBluetoothDeviceDiscoveryAgent *discoveryAgent; + QBluetoothSocket *socket = nullptr; + QBluetoothSocket *phoneSocket = nullptr; + QDBusInterface *mprisInterface; + QString connectedDeviceMacAddress; +}; diff --git a/linux/BluetoothHandler.cpp b/linux/BluetoothHandler.cpp new file mode 100644 index 0000000..75ad478 --- /dev/null +++ b/linux/BluetoothHandler.cpp @@ -0,0 +1,109 @@ +#include "BluetoothHandler.h" +#include "PacketDefinitions.h" +#include + +Q_LOGGING_CATEGORY(bluetoothHandler, "bluetoothHandler") + +#define LOG_INFO(msg) qCInfo(bluetoothHandler) << "\033[32m" << msg << "\033[0m" +#define LOG_WARN(msg) qCWarning(bluetoothHandler) << "\033[33m" << msg << "\033[0m" +#define LOG_ERROR(msg) qCCritical(bluetoothHandler) << "\033[31m" << msg << "\033[0m" +#define LOG_DEBUG(msg) qCDebug(bluetoothHandler) << "\033[34m" << msg << "\033[0m" + +BluetoothHandler::BluetoothHandler() { + discoveryAgent = new QBluetoothDeviceDiscoveryAgent(); + discoveryAgent->setLowEnergyDiscoveryTimeout(5000); + + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &BluetoothHandler::onDeviceDiscovered); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &BluetoothHandler::onDiscoveryFinished); + discoveryAgent->start(); + LOG_INFO("BluetoothHandler initialized and started device discovery"); +} + +void BluetoothHandler::connectToDevice(const QBluetoothDeviceInfo &device) { + if (socket && socket->isOpen() && socket->peerAddress() == device.address()) { + LOG_INFO("Already connected to the device: " << device.name()); + return; + } + + LOG_INFO("Connecting to device: " << device.name()); + 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(":", "_"); +} + +void BluetoothHandler::parseData(const QByteArray &data) { + LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size()); + if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) { + int mode = data[7] - 1; + LOG_INFO("Noise control mode: " << mode); + if (mode >= 0 && mode <= 3) { + emit noiseControlModeChanged(mode); + } else { + LOG_ERROR("Invalid noise control mode value received: " << mode); + } + } else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) { + bool primaryInEar = data[6] == 0x00; + bool secondaryInEar = data[7] == 0x00; + QString earDetectionStatus = QString("Primary: %1, Secondary: %2") + .arg(primaryInEar ? "In Ear" : "Out of Ear") + .arg(secondaryInEar ? "In Ear" : "Out of Ear"); + LOG_INFO("Ear detection status: " << earDetectionStatus); + emit earDetectionStatusChanged(earDetectionStatus); + } else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) { + int leftLevel = data[9]; + int rightLevel = data[14]; + int caseLevel = data[19]; + QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%") + .arg(leftLevel) + .arg(rightLevel) + .arg(caseLevel); + LOG_INFO("Battery status: " << batteryStatus); + emit batteryStatusChanged(batteryStatus); + } else if (data.size() == 10 && \ No newline at end of file diff --git a/linux/BluetoothHandler.h b/linux/BluetoothHandler.h new file mode 100644 index 0000000..56215c5 --- /dev/null +++ b/linux/BluetoothHandler.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +class BluetoothHandler : public QObject { + Q_OBJECT + +public: + BluetoothHandler(); + void connectToDevice(const QBluetoothDeviceInfo &device); + void parseData(const QByteArray &data); + +signals: + void noiseControlModeChanged(int mode); + void earDetectionStatusChanged(const QString &status); + void batteryStatusChanged(const QString &status); + +private: + QBluetoothSocket *socket = nullptr; + QBluetoothDeviceDiscoveryAgent *discoveryAgent; +}; diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index eb764af..6262b5d 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -10,6 +10,9 @@ qt_standard_project_setup(REQUIRES 6.5) qt_add_executable(applinux main.cpp + AirPodsTrayApp.cpp + BluetoothHandler.cpp + PacketDefinitions.cpp ) qt_add_qml_module(applinux