mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-30 14:50:48 +00:00
Compare commits
10 Commits
v0.0.3
...
v0.0.3-hot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed26c4fec5 | ||
|
|
46d6cab930 | ||
|
|
5efbfa7ab7 | ||
|
|
6cb29e26d0 | ||
|
|
321a3bd3bf | ||
|
|
5cee33a354 | ||
|
|
055db073da | ||
|
|
c7ef31cba6 | ||
|
|
de53e840ed | ||
|
|
c84195aec8 |
24
README.md
24
README.md
@@ -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.
|
||||
|
||||
|
||||
## Linux — Deprecated, awaiting a rewrite!
|
||||
ANY ISSUES ABOUT THE LINUX VERSION WILL BE CLOSED.
|
||||
## CrossDevice Stuff
|
||||
|
||||
> [!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.
|
||||
|
||||
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!
|
||||
|
||||
https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30
|
||||
https://github.com/user-attachments/assets/470ffc9c-3e52-4dcf-818d-0d1f60986c2e
|
||||
|
||||
| | | |
|
||||
|-------------------|-------------------|-------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  | | |
|
||||
|
||||
### Installation
|
||||
|
||||
|
||||
@@ -23,12 +23,9 @@ import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
@@ -91,6 +88,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
@@ -144,29 +142,6 @@ fun Main() {
|
||||
val context = LocalContext.current
|
||||
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 isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == "CrossDeviceIsAvailable") {
|
||||
@@ -178,20 +153,6 @@ fun Main() {
|
||||
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
|
||||
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
|
||||
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 (
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@@ -122,4 +122,4 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
|
||||
@Composable
|
||||
fun ConversationalAwarenessSwitchPreview() {
|
||||
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") { }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
@@ -20,9 +20,14 @@ package me.kavishdevar.aln.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Context.RECEIVER_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -41,6 +47,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -50,6 +58,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -74,6 +83,7 @@ import dev.chrisbanes.haze.haze
|
||||
import dev.chrisbanes.haze.hazeChild
|
||||
import dev.chrisbanes.haze.materials.CupertinoMaterials
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.composables.AccessibilitySettings
|
||||
import me.kavishdevar.aln.composables.AudioSettings
|
||||
@@ -88,10 +98,11 @@ import me.kavishdevar.aln.ui.theme.ALNTheme
|
||||
import me.kavishdevar.aln.utils.AirPodsNotifications
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@SuppressLint("MissingPermission")
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
@@ -117,9 +128,60 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val verticalScrollState = rememberScrollState()
|
||||
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")
|
||||
Scaffold(
|
||||
@@ -131,47 +193,48 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
topBar = {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val mDensity = remember { mutableFloatStateOf(1f) }
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = deviceName.text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = deviceName.text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (darkMode) Color.White else Color.Black,
|
||||
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
|
||||
.hazeChild(
|
||||
state = hazeState,
|
||||
style = CupertinoMaterials.thick(),
|
||||
block = {
|
||||
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 = {
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
actions = {
|
||||
if (isRemotelyConnected) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navController.navigate("app_settings")
|
||||
showSnackbar("Connected remotely to AirPods via Linux.")
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = Color.Transparent,
|
||||
@@ -179,13 +242,29 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "Settings",
|
||||
imageVector = Icons.Default.Info,
|
||||
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 ->
|
||||
if (isConnected == true || isRemotelyConnected == true) {
|
||||
Column(
|
||||
@@ -311,4 +390,4 @@ fun AirPodsSettingsScreenPreview() {
|
||||
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ import me.kavishdevar.aln.utils.BatteryStatus
|
||||
import me.kavishdevar.aln.utils.CrossDevice
|
||||
import me.kavishdevar.aln.utils.CrossDevicePackets
|
||||
import me.kavishdevar.aln.utils.Enums
|
||||
import me.kavishdevar.aln.utils.IslandType
|
||||
import me.kavishdevar.aln.utils.IslandWindow
|
||||
import me.kavishdevar.aln.utils.LongPressPackets
|
||||
import me.kavishdevar.aln.utils.MediaController
|
||||
@@ -179,10 +180,12 @@ class AirPodsService : Service() {
|
||||
var islandOpen = false
|
||||
var islandWindow: IslandWindow? = null
|
||||
@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")
|
||||
islandWindow = IslandWindow(service.applicationContext)
|
||||
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this, takingOver)
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
islandWindow = IslandWindow(service.applicationContext)
|
||||
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -797,6 +800,8 @@ class AirPodsService : Service() {
|
||||
Log.d("AirPodsService", "Taking over audio")
|
||||
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
|
||||
Log.d("AirPodsService", macAddress)
|
||||
CrossDevice.isAvailable = false
|
||||
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) }
|
||||
device = getSystemService<BluetoothManager>(BluetoothManager::class.java).adapter.bondedDevices.find {
|
||||
it.address == macAddress
|
||||
}
|
||||
@@ -804,7 +809,11 @@ class AirPodsService : Service() {
|
||||
connectToSocket(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")
|
||||
@@ -823,9 +832,7 @@ class AirPodsService : Service() {
|
||||
0x1001,
|
||||
uuid
|
||||
) as BluetoothSocket
|
||||
} catch (
|
||||
e: Exception
|
||||
) {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
try {
|
||||
socket = HiddenApiBypass.newInstance(
|
||||
@@ -838,9 +845,7 @@ class AirPodsService : Service() {
|
||||
0x1001,
|
||||
uuid
|
||||
) as BluetoothSocket
|
||||
} catch (
|
||||
e: Exception
|
||||
) {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -850,10 +855,6 @@ class AirPodsService : Service() {
|
||||
this@AirPodsService.device = device
|
||||
isConnectedLocally = true
|
||||
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.flush()
|
||||
it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
|
||||
@@ -861,7 +862,6 @@ class AirPodsService : Service() {
|
||||
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
|
||||
it.outputStream.flush()
|
||||
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.flush()
|
||||
delay(200)
|
||||
@@ -871,7 +871,6 @@ class AirPodsService : Service() {
|
||||
it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
|
||||
it.outputStream.flush()
|
||||
delay(200)
|
||||
// just in case this doesn't work, send all three after 5 seconds again
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
it.outputStream.write(Enums.HANDSHAKE.value)
|
||||
it.outputStream.flush()
|
||||
@@ -898,6 +897,11 @@ class AirPodsService : Service() {
|
||||
val bytes = buffer.copyOfRange(0, bytesRead)
|
||||
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
|
||||
CrossDevice.sendReceivedPacket(bytes)
|
||||
updateNotificationContent(
|
||||
true,
|
||||
sharedPreferences.getString("name", device.name),
|
||||
batteryNotification.getBattery()
|
||||
)
|
||||
Log.d("AirPods Data", "Data received: $formattedHex")
|
||||
} else if (bytesRead == -1) {
|
||||
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
|
||||
@@ -1122,7 +1126,12 @@ class AirPodsService : Service() {
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
if (!this::socket.isInitialized) return
|
||||
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
|
||||
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
@@ -1137,8 +1146,8 @@ class AirPodsService : Service() {
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "disconnect")
|
||||
isConnectedLocally = false
|
||||
CrossDevice.isAvailable = true
|
||||
}
|
||||
|
||||
fun sendPacket(packet: String) {
|
||||
@@ -1147,7 +1156,7 @@ class AirPodsService : Service() {
|
||||
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray())
|
||||
return
|
||||
}
|
||||
if (this::socket.isInitialized) {
|
||||
if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
|
||||
val byteArray = fromHex.toByteArray()
|
||||
socket.outputStream?.write(byteArray)
|
||||
socket.outputStream?.flush()
|
||||
@@ -1160,7 +1169,7 @@ class AirPodsService : Service() {
|
||||
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
|
||||
return
|
||||
}
|
||||
if (this::socket.isInitialized) {
|
||||
if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
|
||||
socket.outputStream?.write(packet)
|
||||
socket.outputStream?.flush()
|
||||
logPacket(packet, "Sent")
|
||||
@@ -1608,6 +1617,9 @@ class AirPodsService : Service() {
|
||||
e.printStackTrace()
|
||||
}
|
||||
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
||||
// Reset state variables
|
||||
isConnectedLocally = false
|
||||
CrossDevice.isAvailable = true
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
import java.io.IOException
|
||||
@@ -66,6 +67,7 @@ object CrossDevice {
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private const val PACKET_LOG_KEY = "packet_log"
|
||||
private var earDetectionStatus = listOf(false, false)
|
||||
var disconnectionRequested = false
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun init(context: Context) {
|
||||
@@ -84,6 +86,7 @@ object CrossDevice {
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startServer() {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
if (!bluetoothAdapter.isEnabled) return@launch
|
||||
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
|
||||
Log.d("CrossDevice", "Server started")
|
||||
while (serverSocket != null) {
|
||||
@@ -134,6 +137,8 @@ object CrossDevice {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
|
||||
} else {
|
||||
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
|
||||
// Reset state variables
|
||||
isAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,20 +162,37 @@ object CrossDevice {
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleClientConnection(socket: BluetoothSocket) {
|
||||
Log.d("CrossDevice", "Client connected")
|
||||
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
clientSocket = socket
|
||||
val inputStream = socket.inputStream
|
||||
val buffer = ByteArray(1024)
|
||||
var bytes: Int
|
||||
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
|
||||
while (true) {
|
||||
bytes = inputStream.read(buffer)
|
||||
val packet = buffer.copyOf(bytes)
|
||||
try {
|
||||
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")
|
||||
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
|
||||
if (bytes == -1) {
|
||||
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
|
||||
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()
|
||||
disconnectionRequested = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(1000)
|
||||
disconnectionRequested = false
|
||||
}
|
||||
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
|
||||
isAvailable = true
|
||||
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
|
||||
@@ -190,8 +212,14 @@ object CrossDevice {
|
||||
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
|
||||
isAvailable = true
|
||||
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()
|
||||
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) }}")
|
||||
if (ServiceManager.getService()?.isConnectedLocally == true) {
|
||||
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
|
||||
@@ -237,4 +265,13 @@ object CrossDevice {
|
||||
logPacket(byteArray, "Sent")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ import androidx.core.content.ContextCompat.getString
|
||||
import me.kavishdevar.aln.R
|
||||
import me.kavishdevar.aln.services.ServiceManager
|
||||
|
||||
enum class IslandType {
|
||||
CONNECTED,
|
||||
TAKING_OVER,
|
||||
MOVED_TO_REMOTE
|
||||
}
|
||||
|
||||
class IslandWindow(context: Context) {
|
||||
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@SuppressLint("InflateParams")
|
||||
@@ -52,7 +58,7 @@ class IslandWindow(context: Context) {
|
||||
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
|
||||
|
||||
@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
|
||||
else ServiceManager.getService()?.islandOpen = true
|
||||
|
||||
@@ -78,11 +84,13 @@ class IslandWindow(context: Context) {
|
||||
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)
|
||||
} 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,18 +74,17 @@ object MediaController {
|
||||
super.onPlaybackConfigChanged(configs)
|
||||
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $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({
|
||||
iPausedTheMedia = !audioManager.isMusicActive
|
||||
userPlayedTheMedia = audioManager.isMusicActive
|
||||
}, 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 (ServiceManager.getService()?.isConnectedLocally == false) {
|
||||
sendPause(true)
|
||||
pausedForCrossDevice = true
|
||||
}
|
||||
Log.d("MediaController", "Pausing for cross device and taking over.")
|
||||
sendPause(true)
|
||||
pausedForCrossDevice = true
|
||||
ServiceManager.getService()?.takeOver()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,5 @@
|
||||
<string name="island_connected_text">Connected</string>
|
||||
<string name="island_connected_remote_text">Connected to Linux</string>
|
||||
<string name="island_taking_over_text">Moved to phone</string>
|
||||
<string name="island_moved_to_remote_text">Moved to Linux</string>
|
||||
</resources>
|
||||
|
||||
BIN
android/imgs/audio-connected-island.png
Normal file
BIN
android/imgs/audio-connected-island.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
android/imgs/cd-connected-remotely-island.png
Normal file
BIN
android/imgs/cd-connected-remotely-island.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
android/imgs/cd-demo-1.mp4
Normal file
BIN
android/imgs/cd-demo-1.mp4
Normal file
Binary file not shown.
BIN
android/imgs/cd-demo-2.mp4
Normal file
BIN
android/imgs/cd-demo-2.mp4
Normal file
Binary file not shown.
BIN
android/imgs/cd-moved-to-phone-island.png
Normal file
BIN
android/imgs/cd-moved-to-phone-island.png
Normal file
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.
339
linux.old/examples/standalone-old.py
Normal file
339
linux.old/examples/standalone-old.py
Normal 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()
|
||||
@@ -1,188 +1,169 @@
|
||||
# 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 sys
|
||||
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 time
|
||||
import threading
|
||||
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
|
||||
AIRPODS_MAC = "28:2D:7F:C2:05:5B"
|
||||
enums = enums.enums
|
||||
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logging.getLogger().addHandler(logging.StreamHandler())
|
||||
|
||||
class initL2CAP():
|
||||
lastEarStatus = ""
|
||||
earStatus = ""
|
||||
wasMusicPlayingInBoth = False
|
||||
wasMusicPlayingInSingle = False
|
||||
tray_icon = None
|
||||
anc_actions = None
|
||||
|
||||
def pauseMusic(self):
|
||||
subprocess.call(("playerctl", "pause", "--ignore-player", "OnePlus_7"))
|
||||
battery_status = {
|
||||
"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):
|
||||
subprocess.call(("playerctl", "play", "--ignore-player", "OnePlus_7"))
|
||||
logging.info("Playing music")
|
||||
subprocess.call(("playerctl", "play"))
|
||||
|
||||
def getMusicStatus(self):
|
||||
return subprocess.getoutput("playerctl status --ignore-player OnePlus_7").strip()
|
||||
def pauseMusic(self):
|
||||
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"
|
||||
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"
|
||||
logging.debug(f"Handling play/pause with data: {data}, previousStatus: {self.earStatus}, wasMusicPlaying: {self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth}")
|
||||
|
||||
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)
|
||||
def delayed_action(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":
|
||||
elif self.wasMusicPlayingInBoth or s:
|
||||
self.wasMusicPlayingInBoth = True
|
||||
self.wasMusicPlayingInSingle = False
|
||||
|
||||
if first_status and second_status:
|
||||
self.wasMusicPlayingInSingle = False
|
||||
|
||||
if primary_status and secondary_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")
|
||||
s = self.isPlaying()
|
||||
if s:
|
||||
self.pauseMusic()
|
||||
set_card_profile(self.mac_address, "off")
|
||||
logging.info("Setting profile to 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
|
||||
if s:
|
||||
self.wasMusicPlayingInSingle = True
|
||||
else:
|
||||
self.wasMusicPlayingInSingle = False
|
||||
# wasMusicPlayingInSingle = True
|
||||
elif self.earStatus == "Both in":
|
||||
# should be unreachable
|
||||
s = self.getMusicStatus()
|
||||
if s == "Playing":
|
||||
s = self.isPlaying()
|
||||
if s:
|
||||
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:
|
||||
elif not primary_status and not secondary_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")
|
||||
set_card_profile(self.mac_address, "a2dp-sink")
|
||||
logging.info("Setting profile to a2dp-sink")
|
||||
elif self.earStatus == "Only one in":
|
||||
self.stop_thread_event.set()
|
||||
s = self.getMusicStatus()
|
||||
if s == "Playing":
|
||||
s = self.isPlaying()
|
||||
if s:
|
||||
self.wasMusicPlayingInBoth = True
|
||||
if self.wasMusicPlayingInSingle or self.wasMusicPlayingInBoth:
|
||||
self.playMusic()
|
||||
@@ -190,150 +171,304 @@ class initL2CAP():
|
||||
self.wasMusicPlayingInSingle = False
|
||||
self.earStatus = "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":
|
||||
self.stop_thread_event.clear()
|
||||
s = self.getMusicStatus()
|
||||
self.pauseMusic()
|
||||
delayed_thread = threading.Timer(0.3, delayed_action, args=[self, s])
|
||||
s = self.isPlaying()
|
||||
if s:
|
||||
self.pauseMusic()
|
||||
delayed_thread = threading.Timer(0.3, delayed_action, args=[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")
|
||||
set_card_profile(self.mac_address, "a2dp-sink")
|
||||
logging.info("Setting profile to 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)
|
||||
def get_current_volume():
|
||||
result = subprocess.run(["pactl", "get-sink-volume", "@DEFAULT_SINK@"], capture_output=True, text=True)
|
||||
volume_line = result.stdout.splitlines()[0]
|
||||
volume_percent = int(volume_line.split()[4].strip('%'))
|
||||
return volume_percent
|
||||
|
||||
def set_volume(percent):
|
||||
subprocess.run(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"])
|
||||
|
||||
initial_volume = get_current_volume()
|
||||
|
||||
def handle_conversational_awareness(status):
|
||||
if status < 1 or status > 9:
|
||||
logging.error(f"Invalid status: {status}")
|
||||
pass
|
||||
|
||||
|
||||
# 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():
|
||||
global initial_volume
|
||||
|
||||
if status == 1 or status == 2:
|
||||
globals()["initial_volume"] = get_current_volume()
|
||||
new_volume = max(0, min(int(initial_volume * 0.1), 100))
|
||||
elif status == 3:
|
||||
new_volume = max(0, min(int(initial_volume * 0.4), 100))
|
||||
elif status == 6:
|
||||
new_volume = max(0, min(int(initial_volume * 0.5), 100))
|
||||
elif status >= 8:
|
||||
new_volume = initial_volume
|
||||
try:
|
||||
result = subprocess.run(["bluetoothctl", "info", AIRPODS_MAC], capture_output=True, text=True)
|
||||
return "Connected: yes" in result.stdout
|
||||
set_volume(new_volume)
|
||||
except Exception as e:
|
||||
print(f"Error checking Bluetooth connection status: {e}")
|
||||
return False
|
||||
logging.error(f"Error setting volume: {e}")
|
||||
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
|
||||
def connect_bluetooth_device():
|
||||
if is_bluetooth_connected():
|
||||
print("AirPods are already connected.")
|
||||
return
|
||||
if status == 9:
|
||||
logging.getLogger("Conversational Awareness").info("Conversation ended. Restored volume to original level.")
|
||||
|
||||
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])
|
||||
class BatteryStatusUpdater(QObject):
|
||||
battery_status_updated = pyqtSignal(str)
|
||||
anc_mode_updated = pyqtSignal(int)
|
||||
|
||||
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:
|
||||
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}")
|
||||
|
||||
mac_address = get_connected_airpods()
|
||||
if mac_address:
|
||||
connect_to_airpods(mac_address)
|
||||
else:
|
||||
print("Failed to switch audio to AirPods.")
|
||||
except Exception as e:
|
||||
print(f"Error switching audio: {e}")
|
||||
listen_for_device_connections()
|
||||
|
||||
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:
|
||||
# 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}")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
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)
|
||||
# thread.start()
|
||||
|
||||
# thread.stop()
|
||||
main()
|
||||
@@ -215,7 +215,6 @@ def notification_handler(notification_type: int, data: bytes):
|
||||
hex_data = ' '.join(f'{byte:02x}' for byte in data)
|
||||
globals()["notif_unknown"] = hex_data
|
||||
logger.debug(hex_data)
|
||||
|
||||
def main():
|
||||
global running
|
||||
logging.info("Starting AirPods daemon")
|
||||
@@ -261,4 +260,4 @@ if __name__ == "__main__":
|
||||
os.dup2(logfile.fileno(), sys.stdout.fileno())
|
||||
os.dup2(logfile.fileno(), sys.stderr.fileno())
|
||||
|
||||
main()
|
||||
main()
|
||||
@@ -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}")
|
||||
353
linux/main.cpp
353
linux/main.cpp
@@ -13,6 +13,7 @@
|
||||
#include <QInputDialog>
|
||||
#include <QQmlContext>
|
||||
#include <QLoggingCategory>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
@@ -29,6 +30,11 @@
|
||||
#include <QBluetoothDeviceDiscoveryAgent>
|
||||
#include <QBluetoothLocalDevice>
|
||||
#include <QBluetoothUuid>
|
||||
#include <QDBusConnection>
|
||||
#include <QDBusInterface>
|
||||
#include <QDBusReply>
|
||||
#include <QDBusMessage>
|
||||
#include <QDBusPendingCallWatcher>
|
||||
|
||||
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
||||
|
||||
@@ -46,12 +52,26 @@ class AirPodsTrayApp : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
AirPodsTrayApp() {
|
||||
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
|
||||
if (debugMode) {
|
||||
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
||||
} else {
|
||||
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
||||
}
|
||||
LOG_INFO("Initializing AirPodsTrayApp");
|
||||
trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png"));
|
||||
trayMenu = new QMenu();
|
||||
|
||||
bool caState = loadConversationalAwarenessState();
|
||||
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);
|
||||
|
||||
QAction *offAction = new QAction("Off", trayMenu);
|
||||
@@ -120,8 +140,117 @@ public:
|
||||
} else {
|
||||
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:
|
||||
void connectToDevice(const QString &address) {
|
||||
LOG_INFO("Connecting to device with address: " << address);
|
||||
@@ -203,14 +332,28 @@ public slots:
|
||||
}
|
||||
|
||||
void updateBatteryTooltip(const QString &status) {
|
||||
trayIcon->setToolTip(status);
|
||||
trayIcon->setToolTip("Battery Status: " + status);
|
||||
}
|
||||
|
||||
void updateTrayIcon(const QString &status) {
|
||||
QStringList parts = status.split(", ");
|
||||
int leftLevel = parts[0].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);
|
||||
pixmap.fill(Qt::transparent);
|
||||
@@ -227,21 +370,32 @@ public slots:
|
||||
|
||||
void handleEarDetection(const QString &status) {
|
||||
static bool wasPausedByApp = false;
|
||||
|
||||
|
||||
QStringList parts = status.split(", ");
|
||||
bool primaryInEar = parts[0].contains("In Ear");
|
||||
bool secondaryInEar = parts[1].contains("In Ear");
|
||||
|
||||
if (primaryInEar && secondaryInEar) {
|
||||
if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
|
||||
QProcess::execute("playerctl", QStringList() << "play");
|
||||
LOG_INFO("Resumed playback via Playerctl");
|
||||
wasPausedByApp = false;
|
||||
}
|
||||
LOG_INFO("Both AirPods are in ear");
|
||||
|
||||
LOG_DEBUG("Ear detection status: primaryInEar=" << primaryInEar << ", secondaryInEar=" << secondaryInEar << isActiveOutputDeviceAirPods());
|
||||
if (primaryInEar || secondaryInEar) {
|
||||
LOG_INFO("At least one AirPod is in ear");
|
||||
activateA2dpProfile();
|
||||
} 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()) {
|
||||
QProcess process;
|
||||
process.start("playerctl", QStringList() << "status");
|
||||
@@ -249,25 +403,33 @@ public slots:
|
||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||
if (playbackStatus == "Playing") {
|
||||
QProcess::execute("playerctl", QStringList() << "pause");
|
||||
LOG_INFO("Paused playback via Playerctl");
|
||||
wasPausedByApp = true;
|
||||
int result = QProcess::execute("playerctl", QStringList() << "pause");
|
||||
LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
@@ -358,7 +520,7 @@ public slots:
|
||||
}
|
||||
|
||||
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]() {
|
||||
LOG_INFO("Connected to device, sending initial packets");
|
||||
discoveryAgent->stop();
|
||||
@@ -394,7 +556,6 @@ public slots:
|
||||
|
||||
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
|
||||
QByteArray data = localSocket->readAll();
|
||||
LOG_DEBUG("Data received: " << data.toHex());
|
||||
QMetaObject::invokeMethod(this, "parseData", 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"));
|
||||
socket = localSocket;
|
||||
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) {
|
||||
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
|
||||
LOG_DEBUG("Received: " << data.toHex());
|
||||
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
|
||||
int mode = data[7] - 1;
|
||||
LOG_INFO("Noise control mode: " << mode);
|
||||
@@ -431,11 +599,10 @@ public slots:
|
||||
LOG_ERROR("Invalid noise control mode value received: " << mode);
|
||||
}
|
||||
} else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
|
||||
bool primaryInEar = data[6] == 0x00;
|
||||
bool secondaryInEar = data[7] == 0x00;
|
||||
char primary = data[6];
|
||||
char secondary = data[7];
|
||||
QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
||||
.arg(primaryInEar ? "In Ear" : "Out of Ear")
|
||||
.arg(secondaryInEar ? "In Ear" : "Out of Ear");
|
||||
.arg(getEarStatus(primary), getEarStatus(secondary));
|
||||
LOG_INFO("Ear detection status: " << earDetectionStatus);
|
||||
emit earDetectionStatusChanged(earDetectionStatus);
|
||||
} else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
|
||||
@@ -494,7 +661,7 @@ public slots:
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Default sink: " << output);
|
||||
return output.contains("bluez_card." + connectedDeviceMacAddress);
|
||||
return output.contains(connectedDeviceMacAddress.replace(":", "_"));
|
||||
}
|
||||
|
||||
void initializeMprisInterface() {
|
||||
@@ -535,6 +702,14 @@ public slots:
|
||||
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
|
||||
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
|
||||
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) {
|
||||
@@ -548,7 +723,6 @@ public slots:
|
||||
if (phoneSocket && phoneSocket->isOpen()) {
|
||||
QByteArray header = QByteArray::fromHex("00040001");
|
||||
phoneSocket->write(header + packet);
|
||||
LOG_DEBUG("Relayed packet to phone with header: " << (header + packet).toHex());
|
||||
} else {
|
||||
connectToPhone();
|
||||
LOG_WARN("Phone socket is not open, cannot relay packet");
|
||||
@@ -566,8 +740,12 @@ public slots:
|
||||
}
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
||||
LOG_INFO("AirPods connected");
|
||||
isConnectedLocally = true;
|
||||
CrossDevice.isAvailable = false;
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
||||
LOG_INFO("AirPods disconnected");
|
||||
isConnectedLocally = false;
|
||||
CrossDevice.isAvailable = true;
|
||||
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
|
||||
LOG_INFO("Connection status request received");
|
||||
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
|
||||
@@ -583,6 +761,8 @@ public slots:
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput().trimmed();
|
||||
LOG_INFO("Bluetoothctl output: " << output);
|
||||
isConnectedLocally = false;
|
||||
CrossDevice.isAvailable = true;
|
||||
}
|
||||
} else {
|
||||
if (socket && socket->isOpen()) {
|
||||
@@ -600,28 +780,75 @@ public slots:
|
||||
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() {
|
||||
QProcess *playerctlProcess = new QProcess(this);
|
||||
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {
|
||||
QString output = playerctlProcess->readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playerctl output: " << output);
|
||||
if (output == "Playing" && isPhoneConnected()) {
|
||||
LOG_INFO("Media started playing, connecting to AirPods");
|
||||
connectToAirPods();
|
||||
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
||||
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() {
|
||||
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;
|
||||
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
|
||||
for (const QBluetoothAddress &address : connectedDevices) {
|
||||
QBluetoothDeviceInfo device(address, "", 0);
|
||||
LOG_DEBUG("Connected device: " << device.name() << " (" << device.address().toString() << ")");
|
||||
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
||||
connectToDevice(device);
|
||||
return;
|
||||
@@ -630,6 +857,35 @@ public slots:
|
||||
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:
|
||||
void noiseControlModeChanged(int mode);
|
||||
void earDetectionStatusChanged(const QString &status);
|
||||
@@ -643,32 +899,37 @@ private:
|
||||
QBluetoothSocket *phoneSocket = nullptr;
|
||||
QDBusInterface *mprisInterface;
|
||||
QString connectedDeviceMacAddress;
|
||||
QByteArray lastBatteryStatus;
|
||||
QByteArray lastEarDetectionStatus;
|
||||
};
|
||||
|
||||
int main(int argc, char *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;
|
||||
AirPodsTrayApp trayApp;
|
||||
AirPodsTrayApp trayApp(debugMode);
|
||||
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
||||
engine.loadFromModule("linux", "Main");
|
||||
|
||||
QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) {
|
||||
LOG_DEBUG("Received noiseControlModeChanged signal with mode: " << mode);
|
||||
QObject *rootObject = engine.rootObjects().constFirst();
|
||||
|
||||
if (rootObject) {
|
||||
LOG_DEBUG("Root object found");
|
||||
QObject *noiseControlMode = rootObject->findChild<QObject*>("noiseControlMode");
|
||||
if (noiseControlMode) {
|
||||
LOG_DEBUG("noiseControlMode object found");
|
||||
if (mode >= 0 && mode <= 3) {
|
||||
QMetaObject::invokeMethod(noiseControlMode, "setCurrentIndex", Q_ARG(int, mode));
|
||||
} else {
|
||||
LOG_ERROR("Invalid mode value: " << mode);
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR("noiseControlMode object not found");
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
LOG_DEBUG("Received earDetectionStatusChanged signal with status: " << status);
|
||||
QObject *rootObject = engine.rootObjects().first();
|
||||
if (rootObject) {
|
||||
LOG_DEBUG("Root object found");
|
||||
QObject *earDetectionStatus = rootObject->findChild<QObject*>("earDetectionStatus");
|
||||
if (earDetectionStatus) {
|
||||
LOG_DEBUG("earDetectionStatus object found");
|
||||
earDetectionStatus->setProperty("text", "Ear Detection Status: " + status);
|
||||
} else {
|
||||
LOG_ERROR("earDetectionStatus object not found");
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
LOG_DEBUG("Received batteryStatusChanged signal with status: " << status);
|
||||
QObject *rootObject = engine.rootObjects().first();
|
||||
if (rootObject) {
|
||||
LOG_DEBUG("Root object found");
|
||||
QObject *batteryStatus = rootObject->findChild<QObject*>("batteryStatus");
|
||||
if (batteryStatus) {
|
||||
LOG_DEBUG("batteryStatus object found");
|
||||
batteryStatus->setProperty("text", "Battery Status: " + status);
|
||||
} else {
|
||||
LOG_ERROR("batteryStatus object not found");
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR("Root object not found");
|
||||
|
||||
Reference in New Issue
Block a user