finally done with most of the crossdevice stuff!

This commit is contained in:
Kavish Devar
2025-02-01 03:53:37 +05:30
parent 5cee33a354
commit 321a3bd3bf
14 changed files with 576 additions and 146 deletions

View File

@@ -20,16 +20,13 @@ Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a lis
### Features
- **Battery Status**: Get battery status on your Android device when you connect your AirPods to your Linux device.
- **Control AirPods**: Control your AirPods from your Android device when connected to your Linux device, like changing the noise control mode, toggling conversational awareness, and more.
- **Automatic Device Switching**: Automatically switch between your Linux and Android device when you connect your AirPods to one of them.
- **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!
> [!NOTE]
> All this currently works only one way, Linux to Android, i.e. if you connect your AirPods to your Linux device, the features mentioned. The Android app can automaticaly connect to your AirPods when *receiving calls* or *starting media playback*.
Check out the demo below!
| | |
|-------------------|-------------------|
| Connected to Linux, all features of the app available (setting Noise Control mode, battery status, etc.). ![Connected Remotely](/android/imgs/cd-connected-remotely-island.png) | Call received or media started playing; phone took over audio and disconnected AirPods from linux. ![Moved to Phone](/android/imgs/cd-moved-to-phone-island.png) |
https://raw.githubusercontent.com/kavishdevar/aln/main/android/imgs/cd-demo-2.mp4
## Linux — Deprecated, rewrite WIP!

View File

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

View File

@@ -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/>.
*/

View File

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

View File

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

View File

@@ -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
@@ -42,7 +47,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -94,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 {
@@ -128,12 +133,53 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
fun handleRemoteConnection(connected: Boolean) {
isRemotelyConnected = connected
}
fun showSnackbar(message: String) {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = message,
duration = SnackbarDuration.Short
)
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)
}
}

View File

@@ -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)
@@ -806,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")
@@ -1119,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) {
@@ -1134,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) {
@@ -1144,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()
@@ -1157,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")
@@ -1605,6 +1617,9 @@ class AirPodsService : Service() {
e.printStackTrace()
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
// Reset state variables
isConnectedLocally = false
CrossDevice.isAvailable = true
super.onDestroy()
}
}

View File

@@ -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) {
@@ -135,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
}
}
@@ -158,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()
@@ -191,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) }
@@ -238,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)
}
}

View File

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

View File

@@ -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()
}
}

View File

@@ -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/cd-demo-2.mp4 Normal file

Binary file not shown.

View File

@@ -333,8 +333,6 @@ def listen_for_device_connections():
def device_connected(interface, changed, invalidated, path):
# /org/bluez/hci0/dev_mac_address/*
# repl _ with : in mac_address and check
if 'Connected' in changed and changed['Connected']:
if path.split("/")[-1] == "":
return

View File

@@ -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,16 @@ 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();
// Initialize conversational awareness state
bool caState = loadConversationalAwarenessState();
QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu);
caToggleAction->setCheckable(true);
@@ -131,10 +141,116 @@ public:
LOG_WARN("Service record not found, waiting for BLE broadcast");
}
// Set up device listening
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);
@@ -240,21 +356,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");
@@ -262,25 +389,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() {
@@ -371,7 +506,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();
@@ -407,7 +542,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));
});
@@ -431,10 +565,11 @@ public slots:
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
socket = localSocket;
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
notifyAndroidDevice();
}
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);
@@ -507,7 +642,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() {
@@ -548,6 +683,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) {
@@ -561,7 +704,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");
@@ -579,8 +721,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");
@@ -596,6 +742,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()) {
@@ -619,18 +767,6 @@ public slots:
systemBus.connect(QString(), QString(), "org.freedesktop.DBus.ObjectManager", "InterfacesAdded", this, SLOT(onInterfacesAdded(QString, QVariantMap)));
}
void onDevicePropertiesChanged(QString interface, QVariantMap changed, QStringList invalidated) {
if (changed.contains("Connected") && changed["Connected"].toBool()) {
QString path = interface.split("/").last();
QString addr = path.replace("_", ":").replace("dev:", "");
QBluetoothAddress btAddress(addr);
QBluetoothDeviceInfo device(btAddress, "", 0);
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
connectToDevice(device);
}
}
}
void onInterfacesAdded(QString path, QVariantMap interfaces) {
if (interfaces.contains("org.bluez.Device1")) {
QVariantMap deviceProps = interfaces["org.bluez.Device1"].toMap();
@@ -651,22 +787,49 @@ public slots:
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;
@@ -675,6 +838,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);
@@ -688,32 +880,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");
@@ -721,16 +918,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");
@@ -738,16 +930,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");
@@ -760,4 +947,4 @@ int main(int argc, char *argv[]) {
}
#include "main.moc"
#include "main.moc"