10 Commits

Author SHA1 Message Date
Kavish Devar
ed26c4fec5 Merge pull request #46 from tim-gromeyer/linux-airpods-battery-level
[Linux] Show correct battery level when 1 Airpod is disconnected
2025-02-02 10:32:09 +05:30
Tim Gromeyer
46d6cab930 [Linux] Show correct battery level when 1 Airpod is disconnected
It would show 0 previously
2025-02-01 12:47:50 +01:00
Kavish Devar
5efbfa7ab7 Merge pull request #45 from tim-gromeyer/ear-detection-status-in.case
[Linux] Add "in case" as ear detection status
2025-02-01 16:34:25 +05:30
Tim Gromeyer
6cb29e26d0 [Linux] Add "in case" as ear detection status 2025-02-01 11:59:09 +01:00
Kavish Devar
321a3bd3bf finally done with most of the crossdevice stuff! 2025-02-01 03:53:37 +05:30
Kavish Devar
5cee33a354 improve screenshots layout in cd 2025-01-31 03:58:08 +05:30
Kavish Devar
055db073da update video for transitions in android app 2025-01-31 03:55:37 +05:30
Kavish Devar
c7ef31cba6 add cross device stuff (screenshots) in readme 2025-01-31 03:37:02 +05:30
Kavish Devar
de53e840ed for @maxwofford! :) 2025-01-31 01:59:21 +05:30
Kavish Devar
c84195aec8 add info button for when remotely connected 2025-01-30 04:17:07 +05:30
23 changed files with 1479 additions and 484 deletions

View File

@@ -13,8 +13,25 @@ Other devices might work too. Features like ear detection and battery should be
Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list. Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list.
## Linux — Deprecated, awaiting a rewrite! ## CrossDevice Stuff
ANY ISSUES ABOUT THE LINUX VERSION WILL BE CLOSED.
> [!IMPORTANT]
> This feature is still in development and might not work as expected. No support is provided for this feature.
### Features
- **Battery Status**: Get battery status on any device when you connect your AirPods to one of them.
- **Control AirPods**: Control your AirPods from either of your device when you connect to one, like changing the noise control mode, toggling conversational awareness, and more.
- **Automatic Device Switching**: Automatically switch between your Linux and Android device, like when you receive a call, start playing music on Android while you're connected to Linux, and more!
Check out the demo below!
https://raw.githubusercontent.com/kavishdevar/aln/main/android/imgs/cd-demo-2.mp4
## Linux — Deprecated, rewrite WIP!
> No support will be provided for the old version of the Linux app. The new version is still in development and might not work as expected. No support is provided for the new version either.
Check out the README file in [linux](/linux) folder for more info. Check out the README file in [linux](/linux) folder for more info.
This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more. This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more.
@@ -32,13 +49,14 @@ This tray app communicates with a daemon with the help of a UNIX socket. The dae
Check out the new animations and transitions in the app! Check out the new animations and transitions in the app!
https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30 https://github.com/user-attachments/assets/470ffc9c-3e52-4dcf-818d-0d1f60986c2e
| | | | | | | |
|-------------------|-------------------|-------------------| |-------------------|-------------------|-------------------|
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | | ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification](/android/imgs/notification.png) | ![Popup](/android/imgs/popup.png) | ![QuickSetting Tile](/android/imgs/qstile.png) | | ![Battery Notification](/android/imgs/notification.png) | ![Popup](/android/imgs/popup.png) | ![QuickSetting Tile](/android/imgs/qstile.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) | | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) |
| ![audio-popup](/android/imgs/audio-connected-island.png) | | |
### Installation ### Installation

View File

@@ -23,12 +23,9 @@ import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
@@ -91,6 +88,7 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
try { try {
unbindService(serviceConnection) unbindService(serviceConnection)
@@ -144,29 +142,6 @@ fun Main() {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
connectionStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.AIRPODS_CONNECTED) {
Log.d("MainActivity", "AirPods Connected intent received")
isConnected.value = true
}
else if (intent.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
Log.d("MainActivity", "AirPods Disconnected intent received")
isRemotelyConnected.value = CrossDevice.isAvailable
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")
}
}
}
}
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") { if (key == "CrossDeviceIsAvailable") {
@@ -178,20 +153,6 @@ fun Main() {
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}") Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
Log.d("MainActivity", "Registering Receiver")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.registerReceiver(connectionStatusReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(connectionStatusReceiver, filter)
}
Log.d("MainActivity", "Registered Receiver")
Box ( Box (
modifier = Modifier modifier = Modifier
.padding(0.dp) .padding(0.dp)

View File

@@ -1,17 +1,17 @@
/* /*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* *
* Copyright (C) 2024 Kavish Devar * Copyright (C) 2024 Kavish Devar
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License. * by the Free Software Foundation, either version 3 of the License.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */

View File

@@ -1,17 +1,17 @@
/* /*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* *
* Copyright (C) 2024 Kavish Devar * Copyright (C) 2024 Kavish Devar
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License. * by the Free Software Foundation, either version 3 of the License.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@@ -122,4 +122,4 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
@Composable @Composable
fun ConversationalAwarenessSwitchPreview() { fun ConversationalAwarenessSwitchPreview() {
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0)) ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
} }

View File

@@ -0,0 +1,182 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import me.kavishdevar.aln.R
class DropdownItem(val name: String, val onSelect: () -> Unit) {
fun select() {
onSelect()
}
}
@Composable
fun CustomDropdown(name: String, description: String = "", items: List<DropdownItem>) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
var expanded by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(IntOffset.Zero) }
var popupHeight by remember { mutableStateOf(0.dp) }
val animatedHeight by animateDpAsState(
targetValue = if (expanded) popupHeight else 0.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
val animatedScale by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
expanded = true
}
.onGloballyPositioned { coordinates ->
val windowPosition = coordinates.localToWindow(Offset.Zero)
offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = name,
fontSize = 16.sp,
color = textColor,
maxLines = 1
)
if (description.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
maxLines = 1
)
}
}
Text(
text = "\uDBC0\uDD8F",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
}
if (expanded) {
Popup(
alignment = Alignment.TopStart,
offset = offset ,
properties = PopupProperties(focusable = true),
onDismissRequest = { expanded = false }
) {
val density = LocalDensity.current
Column(
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(8.dp))
.padding(8.dp)
.widthIn(max = 50.dp)
.height(animatedHeight)
.scale(animatedScale)
.onGloballyPositioned { coordinates ->
popupHeight = with(density) { coordinates.size.height.toDp() }
}
) {
items.forEach { item ->
Text(
text = item.name,
modifier = Modifier
.fillMaxWidth()
.clickable {
item.select()
expanded = false
}
.padding(8.dp),
color = textColor
)
}
}
}
}
}
@Preview
@Composable
fun CustomDropdownPreview() {
CustomDropdown(
name = "Volume Swipe Speed",
items = listOf(
DropdownItem("Always On") { },
DropdownItem("Off") { },
DropdownItem("Only when speaking") { }
)
)
}

View File

@@ -1,17 +1,17 @@
/* /*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* *
* Copyright (C) 2024 Kavish Devar * Copyright (C) 2024 Kavish Devar
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License. * by the Free Software Foundation, either version 3 of the License.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@@ -20,9 +20,14 @@ package me.kavishdevar.aln.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,6 +39,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -41,6 +47,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -50,6 +58,7 @@ 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.rememberCoroutineScope
import androidx.compose.runtime.setValue 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
@@ -74,6 +83,7 @@ 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.launch
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
import me.kavishdevar.aln.composables.AccessibilitySettings import me.kavishdevar.aln.composables.AccessibilitySettings
import me.kavishdevar.aln.composables.AudioSettings import me.kavishdevar.aln.composables.AudioSettings
@@ -88,10 +98,11 @@ import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.aln.utils.AirPodsNotifications import me.kavishdevar.aln.utils.AirPodsNotifications
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) { navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var device by remember { mutableStateOf(dev) } var device by remember { mutableStateOf(dev) }
var deviceName by remember { var deviceName by remember {
@@ -117,9 +128,60 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
fun handleRemoteConnection(connected: Boolean) {
isRemotelyConnected = connected
}
fun showSnackbar(message: String) {
coroutineScope.launch {
snackbarHostState.showSnackbar(message)
}
}
val context = LocalContext.current
val bluetoothReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(true)
}
} else if (intent?.action == "me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY") {
coroutineScope.launch {
handleRemoteConnection(false)
}
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
addAction("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(bluetoothReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(bluetoothReceiver, filter)
}
onDispose {
context.unregisterReceiver(bluetoothReceiver)
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
Scaffold( Scaffold(
@@ -131,47 +193,48 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
topBar = { topBar = {
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) } val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text(
text = deviceName.text, text = deviceName.text,
style = TextStyle( style = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black, color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeChild(
state = hazeState,
style = CupertinoMaterials.thick(),
block = {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
}
)
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
) )
) }
}, },
modifier = Modifier colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
.hazeChild( containerColor = Color.Transparent
state = hazeState, ),
style = CupertinoMaterials.thick(), actions = {
block = { if (isRemotelyConnected) {
alpha =
if (verticalScrollState.value > 55.dp.value * mDensity.floatValue) 1f else 0f
}
)
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 55.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
IconButton( IconButton(
onClick = { onClick = {
navController.navigate("app_settings") showSnackbar("Connected remotely to AirPods via Linux.")
}, },
colors = IconButtonDefaults.iconButtonColors( colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -179,13 +242,29 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
) )
) { ) {
Icon( Icon(
imageVector = Icons.Default.Settings, imageVector = Icons.Default.Info,
contentDescription = "Settings", contentDescription = "Info",
) )
} }
} }
) IconButton(
} onClick = {
navController.navigate("app_settings")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
)
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues -> ) { paddingValues ->
if (isConnected == true || isRemotelyConnected == true) { if (isConnected == true || isRemotelyConnected == true) {
Column( Column(
@@ -311,4 +390,4 @@ fun AirPodsSettingsScreenPreview() {
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false) AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
} }
} }
} }

View File

@@ -78,6 +78,7 @@ import me.kavishdevar.aln.utils.BatteryStatus
import me.kavishdevar.aln.utils.CrossDevice import me.kavishdevar.aln.utils.CrossDevice
import me.kavishdevar.aln.utils.CrossDevicePackets import me.kavishdevar.aln.utils.CrossDevicePackets
import me.kavishdevar.aln.utils.Enums import me.kavishdevar.aln.utils.Enums
import me.kavishdevar.aln.utils.IslandType
import me.kavishdevar.aln.utils.IslandWindow import me.kavishdevar.aln.utils.IslandWindow
import me.kavishdevar.aln.utils.LongPressPackets import me.kavishdevar.aln.utils.LongPressPackets
import me.kavishdevar.aln.utils.MediaController import me.kavishdevar.aln.utils.MediaController
@@ -179,10 +180,12 @@ class AirPodsService : Service() {
var islandOpen = false var islandOpen = false
var islandWindow: IslandWindow? = null var islandWindow: IslandWindow? = null
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun showIsland(service: Service, batteryPercentage: Int, takingOver: Boolean = false) { fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED) {
Log.d("AirPodsService", "Showing island window") Log.d("AirPodsService", "Showing island window")
islandWindow = IslandWindow(service.applicationContext) CoroutineScope(Dispatchers.Main).launch {
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this, takingOver) islandWindow = IslandWindow(service.applicationContext)
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -797,6 +800,8 @@ class AirPodsService : Service() {
Log.d("AirPodsService", "Taking over audio") Log.d("AirPodsService", "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d("AirPodsService", macAddress) Log.d("AirPodsService", macAddress)
CrossDevice.isAvailable = false
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) }
device = getSystemService<BluetoothManager>(BluetoothManager::class.java).adapter.bondedDevices.find { device = getSystemService<BluetoothManager>(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress it.address == macAddress
} }
@@ -804,7 +809,11 @@ class AirPodsService : Service() {
connectToSocket(device!!) connectToSocket(device!!)
connectAudio(this, device) connectAudio(this, device)
} }
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), true) showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER)
isConnectedLocally = true
CrossDevice.isAvailable = false
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@@ -823,9 +832,7 @@ class AirPodsService : Service() {
0x1001, 0x1001,
uuid uuid
) as BluetoothSocket ) as BluetoothSocket
} catch ( } catch (e: Exception) {
e: Exception
) {
e.printStackTrace() e.printStackTrace()
try { try {
socket = HiddenApiBypass.newInstance( socket = HiddenApiBypass.newInstance(
@@ -838,9 +845,7 @@ class AirPodsService : Service() {
0x1001, 0x1001,
uuid uuid
) as BluetoothSocket ) as BluetoothSocket
} catch ( } catch (e: Exception) {
e: Exception
) {
e.printStackTrace() e.printStackTrace()
} }
} }
@@ -850,10 +855,6 @@ class AirPodsService : Service() {
this@AirPodsService.device = device this@AirPodsService.device = device
isConnectedLocally = true isConnectedLocally = true
socket.let { it -> socket.let { it ->
// sometimes doesn't work ;-;
// i though i move it to the coroutine
// but, the socket sometimes disconnects if i don't send a packet outside of the routine first
// so, sending *again*, with a delay, in the coroutine
it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.flush() it.outputStream.flush()
it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value) it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
@@ -861,7 +862,6 @@ class AirPodsService : Service() {
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
it.outputStream.flush() it.outputStream.flush()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
// this is so stupid, why does it disconnect if i don't send a packet outside of the coroutine first
it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.flush() it.outputStream.flush()
delay(200) delay(200)
@@ -871,7 +871,6 @@ class AirPodsService : Service() {
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
it.outputStream.flush() it.outputStream.flush()
delay(200) delay(200)
// just in case this doesn't work, send all three after 5 seconds again
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper()).postDelayed({
it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.write(Enums.HANDSHAKE.value)
it.outputStream.flush() it.outputStream.flush()
@@ -898,6 +897,11 @@ class AirPodsService : Service() {
val bytes = buffer.copyOfRange(0, bytesRead) val bytes = buffer.copyOfRange(0, bytesRead)
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
CrossDevice.sendReceivedPacket(bytes) CrossDevice.sendReceivedPacket(bytes)
updateNotificationContent(
true,
sharedPreferences.getString("name", device.name),
batteryNotification.getBattery()
)
Log.d("AirPods Data", "Data received: $formattedHex") Log.d("AirPods Data", "Data received: $formattedHex")
} else if (bytesRead == -1) { } else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)") Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
@@ -1122,7 +1126,12 @@ class AirPodsService : Service() {
} }
fun disconnect() { fun disconnect() {
if (!this::socket.isInitialized) return
socket.close() socket.close()
MediaController.pausedForCrossDevice = false
Log.d("AirPodsService", "Disconnected from AirPods, showing island.")
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.MOVED_TO_REMOTE)
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
@@ -1137,8 +1146,8 @@ class AirPodsService : Service() {
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "disconnect")
isConnectedLocally = false isConnectedLocally = false
CrossDevice.isAvailable = true
} }
fun sendPacket(packet: String) { fun sendPacket(packet: String) {
@@ -1147,7 +1156,7 @@ class AirPodsService : Service() {
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray()) CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray())
return return
} }
if (this::socket.isInitialized) { if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
val byteArray = fromHex.toByteArray() val byteArray = fromHex.toByteArray()
socket.outputStream?.write(byteArray) socket.outputStream?.write(byteArray)
socket.outputStream?.flush() socket.outputStream?.flush()
@@ -1160,7 +1169,7 @@ class AirPodsService : Service() {
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
return return
} }
if (this::socket.isInitialized) { if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
logPacket(packet, "Sent") logPacket(packet, "Sent")
@@ -1608,6 +1617,9 @@ class AirPodsService : Service() {
e.printStackTrace() e.printStackTrace()
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
// Reset state variables
isConnectedLocally = false
CrossDevice.isAvailable = true
super.onDestroy() super.onDestroy()
} }
} }

View File

@@ -35,6 +35,7 @@ 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
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.aln.services.ServiceManager import me.kavishdevar.aln.services.ServiceManager
import java.io.IOException import java.io.IOException
@@ -66,6 +67,7 @@ object CrossDevice {
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
private const val PACKET_LOG_KEY = "packet_log" private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false) private var earDetectionStatus = listOf(false, false)
var disconnectionRequested = false
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun init(context: Context) { fun init(context: Context) {
@@ -84,6 +86,7 @@ object CrossDevice {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startServer() { private fun startServer() {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
if (!bluetoothAdapter.isEnabled) return@launch
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid) serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started") Log.d("CrossDevice", "Server started")
while (serverSocket != null) { while (serverSocket != null) {
@@ -134,6 +137,8 @@ object CrossDevice {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet) clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} else { } else {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet) clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
// Reset state variables
isAvailable = true
} }
} }
@@ -157,20 +162,37 @@ object CrossDevice {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) { private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("CrossDevice", "Client connected") Log.d("CrossDevice", "Client connected")
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
clientSocket = socket clientSocket = socket
val inputStream = socket.inputStream val inputStream = socket.inputStream
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
var bytes: Int var bytes: Int
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true) setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
while (true) { while (true) {
bytes = inputStream.read(buffer) try {
val packet = buffer.copyOf(bytes) bytes = inputStream.read(buffer)
} catch (e: IOException) {
e.printStackTrace()
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
val s = serverSocket?.accept()
if (s != null) {
handleClientConnection(s)
}
break
}
var packet = buffer.copyOf(bytes)
logPacket(packet, "Relay") logPacket(packet, "Relay")
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}") Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) { if (bytes == -1) {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnect() ServiceManager.getService()?.disconnect()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
disconnectionRequested = false
}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) { } else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
isAvailable = true isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
@@ -190,8 +212,14 @@ object CrossDevice {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
if (packet.size % 2 == 0) {
val half = packet.size / 2
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
Log.d("CrossDevice", "Duplicated packet, trimming")
packet = packet.sliceArray(0 until half)
}
}
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray() var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}") Log.d("CrossDevice", "Received 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) }
@@ -237,4 +265,13 @@ object CrossDevice {
logPacket(byteArray, "Sent") logPacket(byteArray, "Sent")
Log.d("CrossDevice", "Sent packet to remote device") Log.d("CrossDevice", "Sent packet to remote device")
} }
fun notifyAirPodsConnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.aln.AIRPODS_CONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
fun notifyAirPodsDisconnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.aln.AIRPODS_DISCONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
} }

View File

@@ -42,6 +42,12 @@ import androidx.core.content.ContextCompat.getString
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager import me.kavishdevar.aln.services.ServiceManager
enum class IslandType {
CONNECTED,
TAKING_OVER,
MOVED_TO_REMOTE
}
class IslandWindow(context: Context) { class IslandWindow(context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
@@ -52,7 +58,7 @@ class IslandWindow(context: Context) {
get() = islandView.parent != null && islandView.visibility == View.VISIBLE get() = islandView.parent != null && islandView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) { fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
if (ServiceManager.getService()?.islandOpen == true) return if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true else ServiceManager.getService()?.islandOpen = true
@@ -78,11 +84,13 @@ class IslandWindow(context: Context) {
close() close()
} }
if (takingOver) { if (type == IslandType.TAKING_OVER) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text) islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (type == IslandType.MOVED_TO_REMOTE) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
} else if (CrossDevice.isAvailable) { } else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text) islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else { } else if (type == IslandType.CONNECTED) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text) islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
} }

View File

@@ -74,18 +74,17 @@ object MediaController {
super.onPlaybackConfigChanged(configs) super.onPlaybackConfigChanged(configs)
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia") Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
if (configs != null && !iPausedTheMedia) { if (configs != null && !iPausedTheMedia) {
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't `play` until the ear detection pauses it.") Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
handler.postDelayed({ handler.postDelayed({
iPausedTheMedia = !audioManager.isMusicActive iPausedTheMedia = !audioManager.isMusicActive
userPlayedTheMedia = audioManager.isMusicActive userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something. }, 7) // i have no idea why android sends an event a hundred times after the user does something.
} }
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}") Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) { if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
if (ServiceManager.getService()?.isConnectedLocally == false) { Log.d("MediaController", "Pausing for cross device and taking over.")
sendPause(true) sendPause(true)
pausedForCrossDevice = true pausedForCrossDevice = true
}
ServiceManager.getService()?.takeOver() ServiceManager.getService()?.takeOver()
} }
} }

View File

@@ -45,4 +45,5 @@
<string name="island_connected_text">Connected</string> <string name="island_connected_text">Connected</string>
<string name="island_connected_remote_text">Connected to Linux</string> <string name="island_connected_remote_text">Connected to Linux</string>
<string name="island_taking_over_text">Moved to phone</string> <string name="island_taking_over_text">Moved to phone</string>
<string name="island_moved_to_remote_text">Moved to Linux</string>
</resources> </resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
android/imgs/cd-demo-1.mp4 Normal file

Binary file not shown.

BIN
android/imgs/cd-demo-2.mp4 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

View File

@@ -0,0 +1,339 @@
# AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
#
# Copyright (C) 2024 Kavish Devar
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import threading
import bluetooth
import subprocess
import time
import threading
import os
# Bluetooth MAC address of AirPods
AIRPODS_MAC = "28:2D:7F:C2:05:5B"
class initL2CAP():
lastEarStatus = ""
earStatus = ""
wasMusicPlayingInBoth = False
wasMusicPlayingInSingle = False
def pauseMusic(self):
subprocess.call(("playerctl", "pause", "--ignore-player", "OnePlus_7"))
def playMusic(self):
subprocess.call(("playerctl", "play", "--ignore-player", "OnePlus_7"))
def getMusicStatus(self):
return subprocess.getoutput("playerctl status --ignore-player OnePlus_7").strip()
# Change to MAC address of your AirPods
connected = False
cmd_off = b"\x04\x00\x04\x00\x09\x00\x0d\x01\x00\x00\x00"
cmd_on = b"\x04\x00\x04\x00\x09\x00\x0d\x02\x00\x00\x00"
cmd_transparency = b"\x04\x00\x04\x00\x09\x00\x0d\x03\x00\x00\x00"
cmd_adaptive = b"\x04\x00\x04\x00\x09\x00\x0d\x04\x00\x00\x00"
cmd_ca_off = b"\x04\x00\x04\x00\x09\x00\x28\x02\x00\x00\x00"
cmd_ca_on = b"\x04\x00\x04\x00\x09\x00\x28\x01\x00\x00\x00"
def start(self):
cmd_handshake = b"\x00\x00\x04\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00"
# cmd_smth0 = b"\x04\x00\x04\x00\x0f\x00\xff\xff\xfe\xff"
cmd_smth1 = b"\x04\x00\x04\x00\x0f\x00\xff\xff\xff\xff"
address = "28:2D:7F:C2:05:5B"
aap_service = "74EC2172-0BAD-4D01-8F77-997B2BE0722A"
aap_port = 0x1001
services = bluetooth.find_service(address=address)
service = [s for s in services if s["service-classes"] == [aap_service]]
if not service:
print("Device does not have AAP service")
exit()
self.sock = bluetooth.BluetoothSocket(bluetooth.L2CAP)
sock = self.sock
sock.connect((address, aap_port))
print("Connected to AirPods")
self.connected = True
print("Sending handshake...")
print(sock.type)
sock.send(cmd_handshake)
# sock.send(cmd_smth0)
sock.send(cmd_smth1)
threading.Thread(target=self.listen).start()
# battery info: 04 00 04 00 04 00 03 02 01 64 01 01 04 01 64 01 01 08 01 34 02 01
def parse_battery_status(self, data):
if len(data) != 22:
return
self.left_bud_level = data[9]
self.left_bud_status = data[10]
self.right_bud_level = data[14]
self.right_bud_status = data[15]
self.case_level = data[19]
self.case_status = data[20]
# Interpret the status
def interpret_status(status):
if status == 1:
return "Charging"
elif status == 2:
return "Not charging"
elif status == 4:
return "Disconnected"
else:
return "Unknown"
# Print the results
print(f"Left Bud: {self.left_bud_level}% - {interpret_status(self.left_bud_status)}")
print(f"Right Bud: {self.right_bud_level}% - {interpret_status(self.right_bud_status)}")
print(f"Case: {self.case_level}% - {interpret_status(self.case_status)}")
def parse_anc_status(self, data):
# 04 00 04 00 09 00 0d 03 00 00 00
if len(data) != 11 and data.hex().startswith("040004000600"):
return
if data[7] == 1:
return "Off"
elif data[7] == 2:
return "On"
elif data[7] == 3:
return "Transparency"
elif data[7] == 4:
return "Adaptive"
firstEarOutTime = 0
stop_thread_event = threading.Event()
def parse_inear_status(self, data):
if len(data) != 8:
return
second_status = data[6]
first_status = data[7]
def delayed_action(self, s):
print(s)
if not self.stop_thread_event.is_set():
print("Delayed action")
if self.wasMusicPlayingInSingle:
self.playMusic()
self.wasMusicPlayingInBoth = False
elif self.wasMusicPlayingInBoth or s == "Playing":
self.wasMusicPlayingInBoth = True
self.wasMusicPlayingInSingle = False
if first_status and second_status:
if self.earStatus != "Both out":
s = self.getMusicStatus()
self.pauseMusic()
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B off")
if self.earStatus == "Only one in":
if self.firstEarOutTime != 0 and time.time() - self.firstEarOutTime < 0.3:
print("Only one in called with both out")
self.wasMusicPlayingInSingle = True
self.wasMusicPlayingInBoth = True
self.stop_thread_event.set()
else:
if s == "Playing":
self.wasMusicPlayingInSingle = True
else:
self.wasMusicPlayingInSingle = False
# wasMusicPlayingInSingle = True
elif self.earStatus == "Both in":
# should be unreachable
s = self.getMusicStatus()
if s == "Playing":
self.wasMusicPlayingInBoth = True
self.wasMusicPlayingInSingle = False
else:
self.wasMusicPlayingInSingle = False
self.earStatus = "Both out"
return "Both out"
elif not first_status and not second_status:
if self.earStatus != "Both in":
if self.earStatus == "Both out":
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B a2dp_sink")
elif self.earStatus == "Only one in":
self.stop_thread_event.set()
s = self.getMusicStatus()
if s == "Playing":
self.wasMusicPlayingInBoth = True
if self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth:
self.playMusic()
self.wasMusicPlayingInBoth = True
self.wasMusicPlayingInSingle = False
self.earStatus = "Both in"
return "Both in"
elif (first_status and not second_status) or (not first_status and second_status):
if self.earStatus != "Only one in":
self.stop_thread_event.clear()
s = self.getMusicStatus()
self.pauseMusic()
delayed_thread = threading.Timer(0.3, delayed_action, args=[self, s])
delayed_thread.start()
self.firstEarOutTime = time.time()
if self.earStatus == "Both out":
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B a2dp_sink")
self.earStatus = "Only one in"
return "Only one in"
def listen(self):
while True:
res = self.sock.recv(1024)
print(f"Response: {res.hex()}")
self.battery_status = self.parse_battery_status(res)
self.inear_status = self.parse_inear_status(res)
# anc_status = parse_anc_status(res)
# if anc_status:
# print("ANC: ", anc_status)
if self.battery_status:
print(self.battery_status)
if self.inear_status:
print(self.inear_status)
# while True:
# print("Select command:")
# print("1. Turn off")
# print("2. Turn on")
# print("3. Toggle transparency")
# print("4. Toggle Adaptive")
# print("5. Conversational Awareness On")
# print("6. Conversational Awareness Off")
# print("0. Exit")
# cmd = input("Enter command: ")
# if cmd == "0":
# break
# elif cmd == "1":
# self.sock.send(cmd_off)
# elif cmd == "2":
# self.sock.send(cmd_on)
# elif cmd == "3":
# self.sock.send(cmd_transparency)
# elif cmd == "4":
# self.sock.send(cmd_adaptive)
# elif cmd == "5":
# self.sock.send(cmd_ca_on)
# elif cmd == "6":
# self.sock.send(cmd_ca_off)
def stop(self):
self.connected = False
self.sock.close()
def is_bluetooth_connected():
try:
result = subprocess.run(["bluetoothctl", "info", AIRPODS_MAC], capture_output=True, text=True)
return "Connected: yes" in result.stdout
except Exception as e:
print(f"Error checking Bluetooth connection status: {e}")
return False
# Connect to Bluetooth device using bluetoothctl if not already connected
def connect_bluetooth_device():
if is_bluetooth_connected():
print("AirPods are already connected.")
return
print("Checking if AirPods are available...")
result = subprocess.run(["bluetoothctl", "devices"], capture_output=True, text=True)
if AIRPODS_MAC in result.stdout:
print("AirPods are available. Connecting...")
subprocess.run(["bluetoothctl", "connect", AIRPODS_MAC])
else:
print("AirPods are not available.")
time.sleep(2) # Wait for the connection to establish
# Switch audio output to AirPods (PulseAudio)
try:
result = subprocess.run(["pactl", "list", "short", "sinks"], capture_output=True, text=True)
sink_name = next((line.split()[1] for line in result.stdout.splitlines() if "bluez_sink" in line), None)
if sink_name:
subprocess.run(["pactl", "set-default-sink", sink_name])
print(f"Switched audio to AirPods: {sink_name}")
else:
print("Failed to switch audio to AirPods.")
except Exception as e:
print(f"Error switching audio: {e}")
# Disconnect from Bluetooth device if connected
def disconnect_bluetooth_device():
if not is_bluetooth_connected():
print("AirPods are already disconnected.")
return
print("Disconnecting from AirPods...")
subprocess.run(["bluetoothctl", "disconnect", AIRPODS_MAC])
l2cap = initL2CAP()
# Function to listen to `playerctl --follow` and react to status changes
def mediaListener():
try:
# Run playerctl --follow in a subprocess
process = subprocess.Popen(
["playerctl", "--follow", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
# Continuously read from the subprocess stdout
for line in process.stdout:
if line: # Make sure the line is not empty
line = line.strip() # Remove any extraneous whitespace
print(f"Received event from playerctl: {line}")
if "Playing" in line:
print("Media started playing")
connect_bluetooth_device()
if not l2cap.connected:
l2cap.start()
elif "Paused" in line or "Stopped" in line:
print("Media paused or stopped")
# disconnect_bluetooth_device()
# Check for any errors in stderr
stderr = process.stderr.read()
if stderr:
print(f"Error: {stderr}")
except Exception as e:
print(f"An error occurred in mediaListener: {e}")
mediaListener()
# thread = threading.Thread(target=mediaListener)
# thread.start()
# thread.stop()

View File

@@ -1,188 +1,169 @@
# AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality! import sys
#
# Copyright (C) 2024 Kavish Devar
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import threading import threading
import bluetooth from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction, QMessageBox
from PyQt5.QtGui import QIcon, QPixmap, QPainter
from PyQt5.QtCore import QObject, pyqtSignal, Qt
from PyQt5.QtGui import QFont, QPalette
import logging
import signal
import subprocess import subprocess
import time import time
import threading
import os import os
from aln import Connection, enums
from aln.Notifications import Notifications
import argparse
import dbus
import dbus.mainloop.glib
# Bluetooth MAC address of AirPods enums = enums.enums
AIRPODS_MAC = "28:2D:7F:C2:05:5B" logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logging.getLogger().addHandler(logging.StreamHandler())
class initL2CAP(): tray_icon = None
lastEarStatus = "" anc_actions = None
earStatus = ""
wasMusicPlayingInBoth = False
wasMusicPlayingInSingle = False
def pauseMusic(self): battery_status = {
subprocess.call(("playerctl", "pause", "--ignore-player", "OnePlus_7")) "LEFT": {"status": "Unknown", "level": 0},
"RIGHT": {"status": "Unknown", "level": 0},
"CASE": {"status": "Unknown", "level": 0}
}
anc_mode = 0
battery_status_lock = threading.Lock()
CONVERSATIONAL_AWARENESS_FILE = os.path.expanduser("~/.airpods_conversational_awareness")
CONFIG_FILE = os.path.expanduser("~/.config/aln")
def load_conversational_awareness_state():
if os.path.exists(CONVERSATIONAL_AWARENESS_FILE):
with open(CONVERSATIONAL_AWARENESS_FILE, "r") as file:
return file.read().strip() == "enabled"
return False
def save_conversational_awareness_state(enabled):
with open(CONVERSATIONAL_AWARENESS_FILE, "w") as file:
file.write("enabled" if enabled else "disabled")
def toggle_conversational_awareness():
current_state = load_conversational_awareness_state()
new_state = not current_state
save_conversational_awareness_state(new_state)
connection.send(enums.SET_CONVERSATION_AWARENESS_ON if new_state else enums.SET_CONVERSATION_AWARENESS_OFF)
logging.info(f"Conversational Awareness {'enabled' if new_state else 'disabled'}")
def load_mac_address():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as file:
return file.read().strip()
return None
def save_mac_address(mac_address):
with open(CONFIG_FILE, "w") as file:
file.write(mac_address)
def parse_arguments():
parser = argparse.ArgumentParser(description="Standalone tray application for AirPods.")
parser.add_argument("--mac", help="MAC address of the AirPods")
return parser.parse_args()
def get_connected_airpods():
logging.info("Checking for connected AirPods...")
result = subprocess.run("bluetoothctl devices | cut -f2 -d' ' | while read uuid; do bluetoothctl info $uuid; done | grep -e 'Device\\|Connected\\|Name'", shell=True, capture_output=True, text=True)
lines = result.stdout.splitlines()
for i in range(0, len(lines), 3):
if "Connected: yes" in lines[i + 2]:
addr = lines[i].split()[1]
name = lines[i + 1].split(": ")[1]
logging.debug(f"Checking services for connected device: {name} ({addr})")
services_result = run_sdptool_browse(addr)
if services_result and "UUID 128: 74ec2172-0bad-4d01-8f77-997b2be0722a" in services_result:
logging.info(f"Found connected AirPods: {name} ({addr})")
return addr
logging.error("No connected AirPods found.")
return None
def run_sdptool_browse(addr, retries=5):
for attempt in range(retries):
services_result = subprocess.run(f"sdptool browse {addr}", shell=True, capture_output=True, text=True)
if "Failed to connect to SDP server" not in services_result.stderr:
return services_result.stdout
logging.warning(f"Failed to connect to SDP server on {addr}, attempt {attempt + 1} of {retries}")
time.sleep(1)
logging.error(f"Failed to connect to SDP server on {addr} after {retries} attempts")
return None
def set_card_profile(mac_address, profile):
os.system(f"pactl set-card-profile bluez_card.{mac_address.replace(':', '_')} {profile}")
class MediaController:
def __init__(self, mac_address):
self.mac_address = mac_address
self.earStatus = "Both out"
self.wasMusicPlayingInSingle = False
self.wasMusicPlayingInBoth = False
self.firstEarOutTime = 0
self.stop_thread_event = threading.Event()
def playMusic(self): def playMusic(self):
subprocess.call(("playerctl", "play", "--ignore-player", "OnePlus_7")) logging.info("Playing music")
subprocess.call(("playerctl", "play"))
def getMusicStatus(self): def pauseMusic(self):
return subprocess.getoutput("playerctl status --ignore-player OnePlus_7").strip() logging.info("Pausing music")
subprocess.call(("playerctl", "--all-players", "pause"))
# Change to MAC address of your AirPods def isPlaying(self):
return "Playing" in subprocess.getoutput("playerctl --all-players status").strip()
connected = False def handlePlayPause(self, data):
primary_status = data[0]
secondary_status = data[1]
cmd_off = b"\x04\x00\x04\x00\x09\x00\x0d\x01\x00\x00\x00" logging.debug(f"Handling play/pause with data: {data}, previousStatus: {self.earStatus}, wasMusicPlaying: {self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth}")
cmd_on = b"\x04\x00\x04\x00\x09\x00\x0d\x02\x00\x00\x00"
cmd_transparency = b"\x04\x00\x04\x00\x09\x00\x0d\x03\x00\x00\x00"
cmd_adaptive = b"\x04\x00\x04\x00\x09\x00\x0d\x04\x00\x00\x00"
cmd_ca_off = b"\x04\x00\x04\x00\x09\x00\x28\x02\x00\x00\x00"
cmd_ca_on = b"\x04\x00\x04\x00\x09\x00\x28\x01\x00\x00\x00"
def start(self): def delayed_action(s):
cmd_handshake = b"\x00\x00\x04\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00"
# cmd_smth0 = b"\x04\x00\x04\x00\x0f\x00\xff\xff\xfe\xff"
cmd_smth1 = b"\x04\x00\x04\x00\x0f\x00\xff\xff\xff\xff"
address = "28:2D:7F:C2:05:5B"
aap_service = "74EC2172-0BAD-4D01-8F77-997B2BE0722A"
aap_port = 0x1001
services = bluetooth.find_service(address=address)
service = [s for s in services if s["service-classes"] == [aap_service]]
if not service:
print("Device does not have AAP service")
exit()
self.sock = bluetooth.BluetoothSocket(bluetooth.L2CAP)
sock = self.sock
sock.connect((address, aap_port))
print("Connected to AirPods")
self.connected = True
print("Sending handshake...")
print(sock.type)
sock.send(cmd_handshake)
# sock.send(cmd_smth0)
sock.send(cmd_smth1)
threading.Thread(target=self.listen).start()
# battery info: 04 00 04 00 04 00 03 02 01 64 01 01 04 01 64 01 01 08 01 34 02 01
def parse_battery_status(self, data):
if len(data) != 22:
return
self.left_bud_level = data[9]
self.left_bud_status = data[10]
self.right_bud_level = data[14]
self.right_bud_status = data[15]
self.case_level = data[19]
self.case_status = data[20]
# Interpret the status
def interpret_status(status):
if status == 1:
return "Charging"
elif status == 2:
return "Not charging"
elif status == 4:
return "Disconnected"
else:
return "Unknown"
# Print the results
print(f"Left Bud: {self.left_bud_level}% - {interpret_status(self.left_bud_status)}")
print(f"Right Bud: {self.right_bud_level}% - {interpret_status(self.right_bud_status)}")
print(f"Case: {self.case_level}% - {interpret_status(self.case_status)}")
def parse_anc_status(self, data):
# 04 00 04 00 09 00 0d 03 00 00 00
if len(data) != 11 and data.hex().startswith("040004000600"):
return
if data[7] == 1:
return "Off"
elif data[7] == 2:
return "On"
elif data[7] == 3:
return "Transparency"
elif data[7] == 4:
return "Adaptive"
firstEarOutTime = 0
stop_thread_event = threading.Event()
def parse_inear_status(self, data):
if len(data) != 8:
return
second_status = data[6]
first_status = data[7]
def delayed_action(self, s):
print(s)
if not self.stop_thread_event.is_set(): if not self.stop_thread_event.is_set():
print("Delayed action")
if self.wasMusicPlayingInSingle: if self.wasMusicPlayingInSingle:
self.playMusic() self.playMusic()
self.wasMusicPlayingInBoth = False self.wasMusicPlayingInBoth = False
elif self.wasMusicPlayingInBoth or s == "Playing": elif self.wasMusicPlayingInBoth or s:
self.wasMusicPlayingInBoth = True self.wasMusicPlayingInBoth = True
self.wasMusicPlayingInSingle = False self.wasMusicPlayingInSingle = False
if first_status and second_status: if primary_status and secondary_status:
if self.earStatus != "Both out": if self.earStatus != "Both out":
s = self.getMusicStatus() s = self.isPlaying()
self.pauseMusic() if s:
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B off") self.pauseMusic()
set_card_profile(self.mac_address, "off")
logging.info("Setting profile to off")
if self.earStatus == "Only one in": if self.earStatus == "Only one in":
if self.firstEarOutTime != 0 and time.time() - self.firstEarOutTime < 0.3: if self.firstEarOutTime != 0 and time.time() - self.firstEarOutTime < 0.3:
print("Only one in called with both out")
self.wasMusicPlayingInSingle = True self.wasMusicPlayingInSingle = True
self.wasMusicPlayingInBoth = True self.wasMusicPlayingInBoth = True
self.stop_thread_event.set() self.stop_thread_event.set()
else: else:
if s == "Playing": if s:
self.wasMusicPlayingInSingle = True self.wasMusicPlayingInSingle = True
else: else:
self.wasMusicPlayingInSingle = False self.wasMusicPlayingInSingle = False
# wasMusicPlayingInSingle = True
elif self.earStatus == "Both in": elif self.earStatus == "Both in":
# should be unreachable s = self.isPlaying()
s = self.getMusicStatus() if s:
if s == "Playing":
self.wasMusicPlayingInBoth = True self.wasMusicPlayingInBoth = True
self.wasMusicPlayingInSingle = False self.wasMusicPlayingInSingle = False
else: else:
self.wasMusicPlayingInSingle = False self.wasMusicPlayingInSingle = False
self.earStatus = "Both out" self.earStatus = "Both out"
return "Both out" return "Both out"
elif not first_status and not second_status: elif not primary_status and not secondary_status:
if self.earStatus != "Both in": if self.earStatus != "Both in":
if self.earStatus == "Both out": if self.earStatus == "Both out":
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B a2dp_sink") set_card_profile(self.mac_address, "a2dp-sink")
logging.info("Setting profile to a2dp-sink")
elif self.earStatus == "Only one in": elif self.earStatus == "Only one in":
self.stop_thread_event.set() self.stop_thread_event.set()
s = self.getMusicStatus() s = self.isPlaying()
if s == "Playing": if s:
self.wasMusicPlayingInBoth = True self.wasMusicPlayingInBoth = True
if self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth: if self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth:
self.playMusic() self.playMusic()
@@ -190,150 +171,304 @@ class initL2CAP():
self.wasMusicPlayingInSingle = False self.wasMusicPlayingInSingle = False
self.earStatus = "Both in" self.earStatus = "Both in"
return "Both in" return "Both in"
elif (first_status and not second_status) or (not first_status and second_status): elif (primary_status and not secondary_status) or (not primary_status and secondary_status):
if self.earStatus != "Only one in": if self.earStatus != "Only one in":
self.stop_thread_event.clear() self.stop_thread_event.clear()
s = self.getMusicStatus() s = self.isPlaying()
self.pauseMusic() if s:
delayed_thread = threading.Timer(0.3, delayed_action, args=[self, s]) self.pauseMusic()
delayed_thread = threading.Timer(0.3, delayed_action, args=[s])
delayed_thread.start() delayed_thread.start()
self.firstEarOutTime = time.time() self.firstEarOutTime = time.time()
if self.earStatus == "Both out": if self.earStatus == "Both out":
os.system("pacmd set-card-profile bluez_card.28_2D_7F_C2_05_5B a2dp_sink") set_card_profile(self.mac_address, "a2dp-sink")
logging.info("Setting profile to a2dp-sink")
self.earStatus = "Only one in" self.earStatus = "Only one in"
return "Only one in" return "Only one in"
def listen(self): def get_current_volume():
while True: result = subprocess.run(["pactl", "get-sink-volume", "@DEFAULT_SINK@"], capture_output=True, text=True)
res = self.sock.recv(1024) volume_line = result.stdout.splitlines()[0]
print(f"Response: {res.hex()}") volume_percent = int(volume_line.split()[4].strip('%'))
self.battery_status = self.parse_battery_status(res) return volume_percent
self.inear_status = self.parse_inear_status(res)
# anc_status = parse_anc_status(res) def set_volume(percent):
# if anc_status: subprocess.run(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"])
# print("ANC: ", anc_status)
if self.battery_status: initial_volume = get_current_volume()
print(self.battery_status)
if self.inear_status: def handle_conversational_awareness(status):
print(self.inear_status) if status < 1 or status > 9:
logging.error(f"Invalid status: {status}")
pass
global initial_volume
# while True:
# print("Select command:") if status == 1 or status == 2:
# print("1. Turn off") globals()["initial_volume"] = get_current_volume()
# print("2. Turn on") new_volume = max(0, min(int(initial_volume * 0.1), 100))
# print("3. Toggle transparency") elif status == 3:
# print("4. Toggle Adaptive") new_volume = max(0, min(int(initial_volume * 0.4), 100))
# print("5. Conversational Awareness On") elif status == 6:
# print("6. Conversational Awareness Off") new_volume = max(0, min(int(initial_volume * 0.5), 100))
# print("0. Exit") elif status >= 8:
new_volume = initial_volume
# cmd = input("Enter command: ")
# if cmd == "0":
# break
# elif cmd == "1":
# self.sock.send(cmd_off)
# elif cmd == "2":
# self.sock.send(cmd_on)
# elif cmd == "3":
# self.sock.send(cmd_transparency)
# elif cmd == "4":
# self.sock.send(cmd_adaptive)
# elif cmd == "5":
# self.sock.send(cmd_ca_on)
# elif cmd == "6":
# self.sock.send(cmd_ca_off)
def stop(self):
self.connected = False
self.sock.close()
def is_bluetooth_connected():
try: try:
result = subprocess.run(["bluetoothctl", "info", AIRPODS_MAC], capture_output=True, text=True) set_volume(new_volume)
return "Connected: yes" in result.stdout
except Exception as e: except Exception as e:
print(f"Error checking Bluetooth connection status: {e}") logging.error(f"Error setting volume: {e}")
return False logging.getLogger("Conversational Awareness").info(f"Volume set to {new_volume}% based on conversational awareness status: {status}")
# Connect to Bluetooth device using bluetoothctl if not already connected if status == 9:
def connect_bluetooth_device(): logging.getLogger("Conversational Awareness").info("Conversation ended. Restored volume to original level.")
if is_bluetooth_connected():
print("AirPods are already connected.")
return
print("Checking if AirPods are available...") class BatteryStatusUpdater(QObject):
result = subprocess.run(["bluetoothctl", "devices"], capture_output=True, text=True) battery_status_updated = pyqtSignal(str)
if AIRPODS_MAC in result.stdout: anc_mode_updated = pyqtSignal(int)
print("AirPods are available. Connecting...")
subprocess.run(["bluetoothctl", "connect", AIRPODS_MAC]) def __init__(self, connection, mac_address):
super().__init__()
self.connection = connection
self.media_controller = MediaController(mac_address)
def notification_handler(self, notification_type: int, data: bytes):
global battery_status, anc_mode
logging.debug(f"Received data: {' '.join(f'{byte:02X}' for byte in data)}")
if notification_type == Notifications.BATTERY_UPDATED:
battery = self.connection.notificationListener.BatteryNotification.getBattery()
with battery_status_lock:
battery_status = {
"LEFT": {"status": battery[0].get_status(), "level": battery[0].get_level()},
"RIGHT": {"status": battery[1].get_status(), "level": battery[1].get_level()},
"CASE": {"status": battery[2].get_status(), "level": battery[2].get_level()}
}
status_str = get_battery_status()
self.battery_status_updated.emit(status_str)
logging.debug(f"Updated battery status: {battery_status}")
elif notification_type == Notifications.EAR_DETECTION_UPDATED:
earDetection = self.connection.notificationListener.EarDetectionNotification.getEarDetection()
self.media_controller.handlePlayPause(earDetection)
logging.debug(f"Received ear detection status: {earDetection}")
elif notification_type == Notifications.ANC_UPDATED:
anc_mode = self.connection.notificationListener.ANCNotification.status
self.anc_mode_updated.emit(anc_mode)
logging.debug(f"Received ANC status: {anc_mode}")
elif notification_type == Notifications.CA_UPDATED:
ca_status = self.connection.notificationListener.ConversationalAwarenessNotification.status
handle_conversational_awareness(ca_status)
logging.debug(f"Received CA status: {ca_status}")
def get_battery_status():
global battery_status
with battery_status_lock:
left = battery_status["LEFT"]
right = battery_status["RIGHT"]
case = battery_status["CASE"]
left_status = (left['status'] or 'Unknown').title().replace('_', ' ')
right_status = (right['status'] or 'Unknown').title().replace('_', ' ')
case_status = (case['status'] or 'Unknown').title().replace('_', ' ')
status_emoji = {
"Not Charging": "",
"Charging": "",
}
left_status_emoji = status_emoji.get(left_status, "")
right_status_emoji = status_emoji.get(right_status, "")
case_status_emoji = status_emoji.get(case_status, "")
return f"Left: {left['level']}% {left_status_emoji} | Right: {right['level']}% {right_status_emoji} | Case: {case['level']}% {case_status_emoji}"
def create_battery_icon():
global battery_status
with battery_status_lock:
left_level = battery_status["LEFT"]["level"]
right_level = battery_status["RIGHT"]["level"]
lowest_level = min(left_level, right_level)
icon_size = 64
pixmap = QPixmap(icon_size, icon_size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
is_dark_mode = QApplication.palette().color(QPalette.Window).value() < 128
text_color = Qt.white if is_dark_mode else Qt.black
painter.setPen(text_color)
painter.setFont(QFont('Arial', 20, QFont.Bold))
painter.drawText(pixmap.rect(), Qt.AlignCenter, f"{lowest_level}%")
painter.end()
return QIcon(pixmap)
def signal_handler(sig, frame):
logging.info("Exiting...")
QApplication.quit()
sys.exit(0)
connection=None
battery_status_updater = None
def update_anc_menu(anc_mode, actions):
for action in actions:
action.setChecked(False)
if anc_mode == 1:
actions[0].setChecked(True)
elif anc_mode == 2:
actions[1].setChecked(True)
elif anc_mode == 3:
actions[2].setChecked(True)
elif anc_mode == 4:
actions[3].setChecked(True)
def toggle_conversational_awareness_action(action):
toggle_conversational_awareness()
action.setChecked(load_conversational_awareness_state())
def listen_for_device_connections():
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
bus = dbus.SystemBus()
logging.info("Listening for device connections...")
def device_connected(interface, changed, invalidated, path):
# /org/bluez/hci0/dev_mac_address/*
if 'Connected' in changed and changed['Connected']:
if path.split("/")[-1] == "":
return
addr = path.split("/")[-1].replace("_", ":").replace("dev:", "")
name = changed.get('Name', 'Unknown')
logging.info(f"Device connected: {name} ({addr})")
logging.debug(f"Running command: sdptool browse {addr}")
services_result = run_sdptool_browse(addr)
logging.debug(f"Services result: {services_result}")
if services_result and "UUID 128: 74ec2172-0bad-4d01-8f77-997b2be0722a" in services_result:
logging.info(f"Found connected AirPods: {name} ({addr})")
connect_to_airpods(addr)
bus.add_signal_receiver(device_connected, dbus_interface="org.freedesktop.DBus.Properties", signal_name="PropertiesChanged", path_keyword="path")
def interfaces_added(path, interfaces):
logging.debug(f"Interfaces added: {path}")
if 'org.bluez.Device1' in interfaces and interfaces['org.bluez.Device1'].get('Connected', False):
addr = interfaces['org.bluez.Device1']['Address']
name = interfaces['org.bluez.Device1']['Name']
logging.info(f"Device connected: {name} ({addr})")
if path.endswith("/sep1"):
services_result = run_sdptool_browse(addr)
if services_result and "UUID 128: 74ec2172-0bad-4d01-8f77-997b2be0722a" in services_result:
logging.info(f"Found connected AirPods: {name} ({addr})")
connect_to_airpods(addr)
bus.add_signal_receiver(interfaces_added, dbus_interface="org.freedesktop.DBus.ObjectManager", signal_name="InterfacesAdded")
bus.add_signal_receiver(audio_device_changed, dbus_interface="org.PulseAudio.Core1.Device", signal_name="NewPlaybackStream")
from gi.repository import GLib
loop = GLib.MainLoop()
loop.run()
def audio_device_changed(*args, **kwargs):
logging.info("Audio output device changed, checking for connected AirPods...")
mac_address = get_connected_airpods()
if mac_address:
connect_to_airpods(mac_address)
def connect_to_airpods(mac_address):
logging.info(f"Attempting to connect to AirPods at {mac_address}...")
globals()["connection"] = Connection(mac_address)
try:
connection.connect()
connection.send(enums.HANDSHAKE)
globals()["battery_status_updater"] = BatteryStatusUpdater(connection, mac_address)
connection.initialize_notifications(battery_status_updater.notification_handler)
battery_status_updater.battery_status_updated.connect(lambda status: tray_icon.setToolTip(status))
battery_status_updater.battery_status_updated.connect(lambda: tray_icon.setIcon(create_battery_icon()))
battery_status_updater.anc_mode_updated.connect(lambda mode: update_anc_menu(mode, anc_actions))
save_mac_address(mac_address)
logging.info("Connected to AirPods successfully.")
except Exception as e:
logging.error(f"Failed to connect to AirPods: {e}")
def main():
args = parse_arguments()
mac_address = args.mac or load_mac_address()
logging.debug("Starting standalone tray application...")
app = QApplication(sys.argv)
globals()["tray_icon"] = QSystemTrayIcon(create_battery_icon(), app)
tray_icon.setToolTip(get_battery_status())
menu = QMenu()
ca_toggle_action = QAction("Toggle Conversational Awareness")
ca_toggle_action.setCheckable(True)
ca_toggle_action.setChecked(load_conversational_awareness_state())
ca_toggle_action.triggered.connect(lambda: toggle_conversational_awareness_action(ca_toggle_action))
menu.addAction(ca_toggle_action)
anc_on_action = QAction("Noise Cancellation")
anc_on_action.setCheckable(True)
anc_on_action.triggered.connect(lambda: control_anc("on"))
menu.addAction(anc_on_action)
anc_off_action = QAction("Off")
anc_off_action.setCheckable(True)
anc_off_action.triggered.connect(lambda: control_anc("off"))
menu.addAction(anc_off_action)
anc_transparency_action = QAction("Transparency")
anc_transparency_action.setCheckable(True)
anc_transparency_action.triggered.connect(lambda: control_anc("transparency"))
menu.addAction(anc_transparency_action)
anc_adaptive_action = QAction("Adaptive")
anc_adaptive_action.setCheckable(True)
anc_adaptive_action.triggered.connect(lambda: control_anc("adaptive"))
menu.addAction(anc_adaptive_action)
globals()["anc_actions"] = [anc_off_action, anc_on_action, anc_transparency_action, anc_adaptive_action]
quit_action = QAction("Quit")
quit_action.triggered.connect(lambda: signal_handler(signal.SIGINT, None))
menu.addAction(quit_action)
tray_icon.setContextMenu(menu)
tray_icon.show()
logging.info("Standalone tray application started.")
if mac_address:
connect_to_airpods(mac_address)
else: else:
print("AirPods are not available.") mac_address = get_connected_airpods()
if mac_address:
time.sleep(2) # Wait for the connection to establish connect_to_airpods(mac_address)
# Switch audio output to AirPods (PulseAudio)
try:
result = subprocess.run(["pactl", "list", "short", "sinks"], capture_output=True, text=True)
sink_name = next((line.split()[1] for line in result.stdout.splitlines() if "bluez_sink" in line), None)
if sink_name:
subprocess.run(["pactl", "set-default-sink", sink_name])
print(f"Switched audio to AirPods: {sink_name}")
else: else:
print("Failed to switch audio to AirPods.") listen_for_device_connections()
except Exception as e:
print(f"Error switching audio: {e}") signal.signal(signal.SIGINT, signal_handler)
# Disconnect from Bluetooth device if connected
def disconnect_bluetooth_device():
if not is_bluetooth_connected():
print("AirPods are already disconnected.")
return
print("Disconnecting from AirPods...")
subprocess.run(["bluetoothctl", "disconnect", AIRPODS_MAC])
l2cap = initL2CAP()
# Function to listen to `playerctl --follow` and react to status changes
def mediaListener():
try: try:
# Run playerctl --follow in a subprocess sys.exit(app.exec_())
process = subprocess.Popen(
["playerctl", "--follow", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
# Continuously read from the subprocess stdout
for line in process.stdout:
if line: # Make sure the line is not empty
line = line.strip() # Remove any extraneous whitespace
print(f"Received event from playerctl: {line}")
if "Playing" in line:
print("Media started playing")
connect_bluetooth_device()
if not l2cap.connected:
l2cap.start()
elif "Paused" in line or "Stopped" in line:
print("Media paused or stopped")
# disconnect_bluetooth_device()
# Check for any errors in stderr
stderr = process.stderr.read()
if stderr:
print(f"Error: {stderr}")
except Exception as e: except Exception as e:
print(f"An error occurred in mediaListener: {e}") logging.error(f"An error occurred: {e}")
sys.exit(1)
mediaListener() def control_anc(action):
command = enums.NOISE_CANCELLATION_OFF
if action == "on":
command = enums.NOISE_CANCELLATION_ON
elif action == "off":
command = enums.NOISE_CANCELLATION_OFF
elif action == "transparency":
command = enums.NOISE_CANCELLATION_TRANSPARENCY
elif action == "adaptive":
command = enums.NOISE_CANCELLATION_ADAPTIVE
connection.send(command)
logging.info(f"ANC action: {action}")
# thread = threading.Thread(target=mediaListener) main()
# thread.start()
# thread.stop()

View File

@@ -215,7 +215,6 @@ def notification_handler(notification_type: int, data: bytes):
hex_data = ' '.join(f'{byte:02x}' for byte in data) hex_data = ' '.join(f'{byte:02x}' for byte in data)
globals()["notif_unknown"] = hex_data globals()["notif_unknown"] = hex_data
logger.debug(hex_data) logger.debug(hex_data)
def main(): def main():
global running global running
logging.info("Starting AirPods daemon") logging.info("Starting AirPods daemon")
@@ -261,4 +260,4 @@ if __name__ == "__main__":
os.dup2(logfile.fileno(), sys.stdout.fileno()) os.dup2(logfile.fileno(), sys.stdout.fileno())
os.dup2(logfile.fileno(), sys.stderr.fileno()) os.dup2(logfile.fileno(), sys.stderr.fileno())
main() main()

View File

@@ -1,26 +0,0 @@
# AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
#
# Copyright (C) 2024 Kavish Devar
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import bluetooth
address="28:2D:7F:C2:05:5B"
try:
sock = bluetooth.BluetoothSocket(bluetooth.L2CAP)
sock.connect((address, 0x1001))
sock.send(b"\x00\x00\x04\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00")
except bluetooth.btcommon.BluetoothError as e:
print(f"Error: {e}")

View File

@@ -13,6 +13,7 @@
#include <QInputDialog> #include <QInputDialog>
#include <QQmlContext> #include <QQmlContext>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QThread>
#include <QTimer> #include <QTimer>
#include <QPainter> #include <QPainter>
#include <QPalette> #include <QPalette>
@@ -29,6 +30,11 @@
#include <QBluetoothDeviceDiscoveryAgent> #include <QBluetoothDeviceDiscoveryAgent>
#include <QBluetoothLocalDevice> #include <QBluetoothLocalDevice>
#include <QBluetoothUuid> #include <QBluetoothUuid>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusMessage>
#include <QDBusPendingCallWatcher>
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp") Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
@@ -46,12 +52,26 @@ class AirPodsTrayApp : public QObject {
Q_OBJECT Q_OBJECT
public: public:
AirPodsTrayApp() { AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
if (debugMode) {
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
} else {
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
}
LOG_INFO("Initializing AirPodsTrayApp"); LOG_INFO("Initializing AirPodsTrayApp");
trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png")); trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png"));
trayMenu = new QMenu(); trayMenu = new QMenu();
bool caState = loadConversationalAwarenessState();
QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu); QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu);
caToggleAction->setCheckable(true);
caToggleAction->setChecked(caState);
connect(caToggleAction, &QAction::triggered, this, [this, caToggleAction]() {
bool newState = !caToggleAction->isChecked();
setConversationalAwareness(newState);
saveConversationalAwarenessState(newState);
caToggleAction->setChecked(newState);
});
trayMenu->addAction(caToggleAction); trayMenu->addAction(caToggleAction);
QAction *offAction = new QAction("Off", trayMenu); QAction *offAction = new QAction("Off", trayMenu);
@@ -120,8 +140,117 @@ public:
} else { } else {
LOG_WARN("Service record not found, waiting for BLE broadcast"); LOG_WARN("Service record not found, waiting for BLE broadcast");
} }
listenForDeviceConnections();
initializeDBus();
initializeBluetooth();
} }
~AirPodsTrayApp() {
delete trayIcon;
delete trayMenu;
delete discoveryAgent;
delete bluezInterface;
delete mprisInterface;
delete socket;
delete phoneSocket;
}
private:
bool debugMode;
bool isConnectedLocally = false;
struct {
bool isAvailable = true;
} CrossDevice;
void initializeDBus() {
QDBusConnection systemBus = QDBusConnection::systemBus();
if (!systemBus.isConnected()) {
}
bluezInterface = new QDBusInterface("org.bluez",
"/",
"org.freedesktop.DBus.ObjectManager",
systemBus,
this);
if (!bluezInterface->isValid()) {
LOG_ERROR("Failed to connect to org.bluez DBus interface.");
return;
}
connect(systemBus.interface(), &QDBusConnectionInterface::NameOwnerChanged,
this, &AirPodsTrayApp::onNameOwnerChanged);
systemBus.connect(QString(), QString(), "org.freedesktop.DBus.Properties", "PropertiesChanged",
this, SLOT(onDevicePropertiesChanged(QString, QVariantMap, QStringList)));
systemBus.connect(QString(), QString(), "org.freedesktop.DBus.ObjectManager", "InterfacesAdded",
this, SLOT(onInterfacesAdded(QString, QVariantMap)));
QDBusMessage msg = bluezInterface->call("GetManagedObjects");
if (msg.type() == QDBusMessage::ErrorMessage) {
LOG_ERROR("Error getting managed objects: " << msg.errorMessage());
return;
}
QVariantMap objects = qdbus_cast<QVariantMap>(msg.arguments().at(0));
for (auto it = objects.begin(); it != objects.end(); ++it) {
if (it.key().startsWith("/org/bluez/hci0/dev_")) {
LOG_INFO("Existing device: " << it.key());
}
}
QDBusConnection::systemBus().registerObject("/me/kavishdevar/aln", this);
QDBusConnection::systemBus().registerService("me.kavishdevar.aln");
}
void notifyAndroidDevice() {
if (phoneSocket && phoneSocket->isOpen()) {
QByteArray notificationPacket = QByteArray::fromHex("00040001");
phoneSocket->write(notificationPacket);
LOG_DEBUG("Sent notification packet to Android: " << notificationPacket.toHex());
} else {
LOG_WARN("Phone socket is not open, cannot send notification packet");
}
}
void onNameOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner) {
if (name == "org.bluez") {
if (newOwner.isEmpty()) {
LOG_WARN("BlueZ has been stopped.");
} else {
LOG_INFO("BlueZ started.");
}
}
}
void onDevicePropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated) {
if (interface != "org.bluez.Device1")
return;
if (changed.contains("Connected")) {
bool connected = changed.value("Connected").toBool();
QString devicePath = sender()->objectName();
LOG_INFO(QString("Device %1 connected: %2").arg(devicePath, connected ? "Yes" : "No"));
if (connected) {
const QBluetoothAddress address = QBluetoothAddress(devicePath.split("/").last().replace("_", ":"));
QBluetoothDeviceInfo device(address, "", 0);
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
connectToDevice(device);
}
} else {
disconnectDevice(devicePath);
}
}
}
void disconnectDevice(const QString &devicePath) {
LOG_INFO("Disconnecting device at " << devicePath);
}
QDBusInterface *bluezInterface = nullptr;
public slots: public slots:
void connectToDevice(const QString &address) { void connectToDevice(const QString &address) {
LOG_INFO("Connecting to device with address: " << address); LOG_INFO("Connecting to device with address: " << address);
@@ -203,14 +332,28 @@ public slots:
} }
void updateBatteryTooltip(const QString &status) { void updateBatteryTooltip(const QString &status) {
trayIcon->setToolTip(status); trayIcon->setToolTip("Battery Status: " + status);
} }
void updateTrayIcon(const QString &status) { void updateTrayIcon(const QString &status) {
QStringList parts = status.split(", "); QStringList parts = status.split(", ");
int leftLevel = parts[0].split(": ")[1].replace("%", "").toInt(); int leftLevel = parts[0].split(": ")[1].replace("%", "").toInt();
int rightLevel = parts[1].split(": ")[1].replace("%", "").toInt(); int rightLevel = parts[1].split(": ")[1].replace("%", "").toInt();
int minLevel = qMin(leftLevel, rightLevel);
int minLevel;
if (leftLevel == 0)
{
minLevel = rightLevel;
}
else if (rightLevel == 0)
{
minLevel = leftLevel;
}
else
{
minLevel = qMin(leftLevel, rightLevel);
}
QPixmap pixmap(32, 32); QPixmap pixmap(32, 32);
pixmap.fill(Qt::transparent); pixmap.fill(Qt::transparent);
@@ -227,21 +370,32 @@ public slots:
void handleEarDetection(const QString &status) { void handleEarDetection(const QString &status) {
static bool wasPausedByApp = false; static bool wasPausedByApp = false;
QStringList parts = status.split(", "); QStringList parts = status.split(", ");
bool primaryInEar = parts[0].contains("In Ear"); bool primaryInEar = parts[0].contains("In Ear");
bool secondaryInEar = parts[1].contains("In Ear"); bool secondaryInEar = parts[1].contains("In Ear");
if (primaryInEar && secondaryInEar) { LOG_DEBUG("Ear detection status: primaryInEar=" << primaryInEar << ", secondaryInEar=" << secondaryInEar << isActiveOutputDeviceAirPods());
if (wasPausedByApp && isActiveOutputDeviceAirPods()) { if (primaryInEar || secondaryInEar) {
QProcess::execute("playerctl", QStringList() << "play"); LOG_INFO("At least one AirPod is in ear");
LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false;
}
LOG_INFO("Both AirPods are in ear");
activateA2dpProfile(); activateA2dpProfile();
} else { } else {
LOG_INFO("At least one AirPod is out of ear"); LOG_INFO("Both AirPods are out of ear");
removeAudioOutputDevice();
}
if (primaryInEar && secondaryInEar) {
if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
int result = QProcess::execute("playerctl", QStringList() << "play");
LOG_DEBUG("Executed 'playerctl play' with result: " << result);
if (result == 0) {
LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false;
} else {
LOG_ERROR("Failed to resume playback via Playerctl");
}
}
} else {
if (isActiveOutputDeviceAirPods()) { if (isActiveOutputDeviceAirPods()) {
QProcess process; QProcess process;
process.start("playerctl", QStringList() << "status"); process.start("playerctl", QStringList() << "status");
@@ -249,25 +403,33 @@ public slots:
QString playbackStatus = process.readAllStandardOutput().trimmed(); QString playbackStatus = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Playback status: " << playbackStatus); LOG_DEBUG("Playback status: " << playbackStatus);
if (playbackStatus == "Playing") { if (playbackStatus == "Playing") {
QProcess::execute("playerctl", QStringList() << "pause"); int result = QProcess::execute("playerctl", QStringList() << "pause");
LOG_INFO("Paused playback via Playerctl"); LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
wasPausedByApp = true; if (result == 0) {
LOG_INFO("Paused playback via Playerctl");
wasPausedByApp = true;
} else {
LOG_ERROR("Failed to pause playback via Playerctl");
}
} }
} }
if (!primaryInEar && !secondaryInEar) {
removeAudioOutputDevice();
}
} }
} }
void activateA2dpProfile() { void activateA2dpProfile() {
LOG_INFO("Activating A2DP profile for AirPods"); LOG_INFO("Activating A2DP profile for AirPods");
QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress << "a2dp-sink"); int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "a2dp-sink");
if (result != 0) {
LOG_ERROR("Failed to activate A2DP profile");
}
} }
void removeAudioOutputDevice() { void removeAudioOutputDevice() {
LOG_INFO("Removing AirPods as audio output device"); LOG_INFO("Removing AirPods as audio output device");
QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress << "off"); int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "off");
if (result != 0) {
LOG_ERROR("Failed to remove AirPods as audio output device");
}
} }
bool loadConversationalAwarenessState() { bool loadConversationalAwarenessState() {
@@ -358,7 +520,7 @@ public slots:
} }
LOG_INFO("Connecting to device: " << device.name()); LOG_INFO("Connecting to device: " << device.name());
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::RfcommProtocol); QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() { connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
LOG_INFO("Connected to device, sending initial packets"); LOG_INFO("Connected to device, sending initial packets");
discoveryAgent->stop(); discoveryAgent->stop();
@@ -394,7 +556,6 @@ public slots:
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() { connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
QByteArray data = localSocket->readAll(); QByteArray data = localSocket->readAll();
LOG_DEBUG("Data received: " << data.toHex());
QMetaObject::invokeMethod(this, "parseData", Qt::QueuedConnection, Q_ARG(QByteArray, data)); QMetaObject::invokeMethod(this, "parseData", Qt::QueuedConnection, Q_ARG(QByteArray, data));
QMetaObject::invokeMethod(this, "relayPacketToPhone", Qt::QueuedConnection, Q_ARG(QByteArray, data)); QMetaObject::invokeMethod(this, "relayPacketToPhone", Qt::QueuedConnection, Q_ARG(QByteArray, data));
}); });
@@ -418,10 +579,17 @@ public slots:
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
socket = localSocket; socket = localSocket;
connectedDeviceMacAddress = device.address().toString().replace(":", "_"); connectedDeviceMacAddress = device.address().toString().replace(":", "_");
notifyAndroidDevice();
}
QString getEarStatus(char value)
{
return (value == 0x00) ? "In Ear" : (value == 0x01) ? "Out of Ear"
: "In case";
} }
void parseData(const QByteArray &data) { void parseData(const QByteArray &data) {
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size()); LOG_DEBUG("Received: " << data.toHex());
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) { if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
int mode = data[7] - 1; int mode = data[7] - 1;
LOG_INFO("Noise control mode: " << mode); LOG_INFO("Noise control mode: " << mode);
@@ -431,11 +599,10 @@ public slots:
LOG_ERROR("Invalid noise control mode value received: " << mode); LOG_ERROR("Invalid noise control mode value received: " << mode);
} }
} else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) { } else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
bool primaryInEar = data[6] == 0x00; char primary = data[6];
bool secondaryInEar = data[7] == 0x00; char secondary = data[7];
QString earDetectionStatus = QString("Primary: %1, Secondary: %2") QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
.arg(primaryInEar ? "In Ear" : "Out of Ear") .arg(getEarStatus(primary), getEarStatus(secondary));
.arg(secondaryInEar ? "In Ear" : "Out of Ear");
LOG_INFO("Ear detection status: " << earDetectionStatus); LOG_INFO("Ear detection status: " << earDetectionStatus);
emit earDetectionStatusChanged(earDetectionStatus); emit earDetectionStatusChanged(earDetectionStatus);
} else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) { } else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
@@ -494,7 +661,7 @@ public slots:
process.waitForFinished(); process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed(); QString output = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Default sink: " << output); LOG_DEBUG("Default sink: " << output);
return output.contains("bluez_card." + connectedDeviceMacAddress); return output.contains(connectedDeviceMacAddress.replace(":", "_"));
} }
void initializeMprisInterface() { void initializeMprisInterface() {
@@ -535,6 +702,14 @@ public slots:
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() { connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
LOG_INFO("Connected to phone"); LOG_INFO("Connected to phone");
if (!lastBatteryStatus.isEmpty()) {
phoneSocket->write(lastBatteryStatus);
LOG_DEBUG("Sent last battery status to phone: " << lastBatteryStatus.toHex());
}
if (!lastEarDetectionStatus.isEmpty()) {
phoneSocket->write(lastEarDetectionStatus);
LOG_DEBUG("Sent last ear detection status to phone: " << lastEarDetectionStatus.toHex());
}
}); });
connect(phoneSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this](QBluetoothSocket::SocketError error) { connect(phoneSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this](QBluetoothSocket::SocketError error) {
@@ -548,7 +723,6 @@ public slots:
if (phoneSocket && phoneSocket->isOpen()) { if (phoneSocket && phoneSocket->isOpen()) {
QByteArray header = QByteArray::fromHex("00040001"); QByteArray header = QByteArray::fromHex("00040001");
phoneSocket->write(header + packet); phoneSocket->write(header + packet);
LOG_DEBUG("Relayed packet to phone with header: " << (header + packet).toHex());
} else { } else {
connectToPhone(); connectToPhone();
LOG_WARN("Phone socket is not open, cannot relay packet"); LOG_WARN("Phone socket is not open, cannot relay packet");
@@ -566,8 +740,12 @@ public slots:
} }
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) { } else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
LOG_INFO("AirPods connected"); LOG_INFO("AirPods connected");
isConnectedLocally = true;
CrossDevice.isAvailable = false;
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) { } else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
LOG_INFO("AirPods disconnected"); LOG_INFO("AirPods disconnected");
isConnectedLocally = false;
CrossDevice.isAvailable = true;
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) { } else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
LOG_INFO("Connection status request received"); LOG_INFO("Connection status request received");
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000"); QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
@@ -583,6 +761,8 @@ public slots:
process.waitForFinished(); process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed(); QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output); LOG_INFO("Bluetoothctl output: " << output);
isConnectedLocally = false;
CrossDevice.isAvailable = true;
} }
} else { } else {
if (socket && socket->isOpen()) { if (socket && socket->isOpen()) {
@@ -600,28 +780,75 @@ public slots:
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data)); QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
} }
void listenForDeviceConnections() {
QDBusConnection systemBus = QDBusConnection::systemBus();
systemBus.connect(QString(), QString(), "org.freedesktop.DBus.Properties", "PropertiesChanged", this, SLOT(onDevicePropertiesChanged(QString, QVariantMap, QStringList)));
systemBus.connect(QString(), QString(), "org.freedesktop.DBus.ObjectManager", "InterfacesAdded", this, SLOT(onInterfacesAdded(QString, QVariantMap)));
}
void onInterfacesAdded(QString path, QVariantMap interfaces) {
if (interfaces.contains("org.bluez.Device1")) {
QVariantMap deviceProps = interfaces["org.bluez.Device1"].toMap();
if (deviceProps.contains("Connected") && deviceProps["Connected"].toBool()) {
QString addr = deviceProps["Address"].toString();
QBluetoothAddress btAddress(addr);
QBluetoothDeviceInfo device(btAddress, "", 0);
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
connectToDevice(device);
}
}
}
}
public: void followMediaChanges() { public: void followMediaChanges() {
QProcess *playerctlProcess = new QProcess(this); QProcess *playerctlProcess = new QProcess(this);
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() { connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {
QString output = playerctlProcess->readAllStandardOutput().trimmed(); QString output = playerctlProcess->readAllStandardOutput().trimmed();
LOG_DEBUG("Playerctl output: " << output); LOG_DEBUG("Playerctl output: " << output);
if (output == "Playing" && isPhoneConnected()) { if (output == "Playing" && isPhoneConnected()) {
LOG_INFO("Media started playing, connecting to AirPods"); LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
connectToAirPods(); sendDisconnectRequestToAndroid();
connectToAirPods(true);
} }
}); });
playerctlProcess->start("playerctl", QStringList() << "metadata" << "--follow" << "status"); playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
}
void sendDisconnectRequestToAndroid() {
if (phoneSocket && phoneSocket->isOpen()) {
QByteArray disconnectRequest = QByteArray::fromHex("00020000");
phoneSocket->write(disconnectRequest);
LOG_DEBUG("Sent disconnect request to Android: " << disconnectRequest.toHex());
} else {
LOG_WARN("Phone socket is not open, cannot send disconnect request");
}
} }
bool isPhoneConnected() { bool isPhoneConnected() {
return phoneSocket && phoneSocket->isOpen(); return phoneSocket && phoneSocket->isOpen();
} }
void connectToAirPods() { void connectToAirPods(bool force) {
if (force) {
LOG_INFO("Forcing connection to AirPods");
QProcess process;
process.start("bluetoothctl", QStringList() << "connect" << connectedDeviceMacAddress.replace("_", ":"));
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
if (output.contains("Connection successful")) {
LOG_INFO("Connection successful, proceeding with L2CAP connection");
QBluetoothAddress btAddress(connectedDeviceMacAddress.replace("_", ":"));
forceL2capConnection(btAddress);
} else {
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
}
}
QBluetoothLocalDevice localDevice; QBluetoothLocalDevice localDevice;
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices(); const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
for (const QBluetoothAddress &address : connectedDevices) { for (const QBluetoothAddress &address : connectedDevices) {
QBluetoothDeviceInfo device(address, "", 0); QBluetoothDeviceInfo device(address, "", 0);
LOG_DEBUG("Connected device: " << 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"))) {
connectToDevice(device); connectToDevice(device);
return; return;
@@ -630,6 +857,35 @@ public slots:
LOG_WARN("AirPods not found among connected devices"); LOG_WARN("AirPods not found among connected devices");
} }
void forceL2capConnection(const QBluetoothAddress &address) {
LOG_INFO("Retrying L2CAP connection for up to 10 seconds...");
QBluetoothDeviceInfo device(address, "", 0);
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 10000) {
QProcess bcProcess;
bcProcess.start("bluetoothctl", QStringList() << "connect" << address.toString());
bcProcess.waitForFinished();
QString output = bcProcess.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
if (output.contains("Connection successful")) {
connectToDevice(device);
QThread::sleep(1);
if (socket && socket->isOpen()) {
LOG_INFO("Successfully connected to device: " << address.toString());
return;
}
} else {
LOG_WARN("Connection attempt failed, retrying...");
}
}
LOG_ERROR("Failed to connect to device within 10 seconds: " << address.toString());
}
void initializeBluetooth() {
connectToPhone();
}
signals: signals:
void noiseControlModeChanged(int mode); void noiseControlModeChanged(int mode);
void earDetectionStatusChanged(const QString &status); void earDetectionStatusChanged(const QString &status);
@@ -643,32 +899,37 @@ private:
QBluetoothSocket *phoneSocket = nullptr; QBluetoothSocket *phoneSocket = nullptr;
QDBusInterface *mprisInterface; QDBusInterface *mprisInterface;
QString connectedDeviceMacAddress; QString connectedDeviceMacAddress;
QByteArray lastBatteryStatus;
QByteArray lastEarDetectionStatus;
}; };
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
QApplication app(argc, argv); QApplication app(argc, argv);
bool debugMode = false;
for (int i = 1; i < argc; ++i) {
if (QString(argv[i]) == "--debug") {
debugMode = true;
break;
}
}
QQmlApplicationEngine engine; QQmlApplicationEngine engine;
AirPodsTrayApp trayApp; AirPodsTrayApp trayApp(debugMode);
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp); engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
engine.loadFromModule("linux", "Main"); engine.loadFromModule("linux", "Main");
QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) { QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) {
LOG_DEBUG("Received noiseControlModeChanged signal with mode: " << mode);
QObject *rootObject = engine.rootObjects().constFirst(); QObject *rootObject = engine.rootObjects().constFirst();
if (rootObject) { if (rootObject) {
LOG_DEBUG("Root object found");
QObject *noiseControlMode = rootObject->findChild<QObject*>("noiseControlMode"); QObject *noiseControlMode = rootObject->findChild<QObject*>("noiseControlMode");
if (noiseControlMode) { if (noiseControlMode) {
LOG_DEBUG("noiseControlMode object found");
if (mode >= 0 && mode <= 3) { if (mode >= 0 && mode <= 3) {
QMetaObject::invokeMethod(noiseControlMode, "setCurrentIndex", Q_ARG(int, mode)); QMetaObject::invokeMethod(noiseControlMode, "setCurrentIndex", Q_ARG(int, mode));
} else { } else {
LOG_ERROR("Invalid mode value: " << mode); LOG_ERROR("Invalid mode value: " << mode);
} }
} else {
LOG_ERROR("noiseControlMode object not found");
} }
} else { } else {
LOG_ERROR("Root object not found"); LOG_ERROR("Root object not found");
@@ -676,16 +937,11 @@ int main(int argc, char *argv[]) {
}); });
QObject::connect(&trayApp, &AirPodsTrayApp::earDetectionStatusChanged, [&engine](const QString &status) { QObject::connect(&trayApp, &AirPodsTrayApp::earDetectionStatusChanged, [&engine](const QString &status) {
LOG_DEBUG("Received earDetectionStatusChanged signal with status: " << status);
QObject *rootObject = engine.rootObjects().first(); QObject *rootObject = engine.rootObjects().first();
if (rootObject) { if (rootObject) {
LOG_DEBUG("Root object found");
QObject *earDetectionStatus = rootObject->findChild<QObject*>("earDetectionStatus"); QObject *earDetectionStatus = rootObject->findChild<QObject*>("earDetectionStatus");
if (earDetectionStatus) { if (earDetectionStatus) {
LOG_DEBUG("earDetectionStatus object found");
earDetectionStatus->setProperty("text", "Ear Detection Status: " + status); earDetectionStatus->setProperty("text", "Ear Detection Status: " + status);
} else {
LOG_ERROR("earDetectionStatus object not found");
} }
} else { } else {
LOG_ERROR("Root object not found"); LOG_ERROR("Root object not found");
@@ -693,16 +949,11 @@ int main(int argc, char *argv[]) {
}); });
QObject::connect(&trayApp, &AirPodsTrayApp::batteryStatusChanged, [&engine](const QString &status) { QObject::connect(&trayApp, &AirPodsTrayApp::batteryStatusChanged, [&engine](const QString &status) {
LOG_DEBUG("Received batteryStatusChanged signal with status: " << status);
QObject *rootObject = engine.rootObjects().first(); QObject *rootObject = engine.rootObjects().first();
if (rootObject) { if (rootObject) {
LOG_DEBUG("Root object found");
QObject *batteryStatus = rootObject->findChild<QObject*>("batteryStatus"); QObject *batteryStatus = rootObject->findChild<QObject*>("batteryStatus");
if (batteryStatus) { if (batteryStatus) {
LOG_DEBUG("batteryStatus object found");
batteryStatus->setProperty("text", "Battery Status: " + status); batteryStatus->setProperty("text", "Battery Status: " + status);
} else {
LOG_ERROR("batteryStatus object not found");
} }
} else { } else {
LOG_ERROR("Root object not found"); LOG_ERROR("Root object not found");