some progress on cross-device, and new dynamic island thingy!

This commit is contained in:
Kavish Devar
2025-01-30 03:49:44 +05:30
parent 8b57a97a54
commit b6966f8c39
19 changed files with 730 additions and 472 deletions

View File

@@ -23,6 +23,7 @@
android:usesPermissionFlags="neverForLocation" android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" /> tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@@ -135,7 +135,8 @@ fun Main() {
permissions = listOf( permissions = listOf(
"android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_SCAN",
"android.permission.POST_NOTIFICATIONS" "android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE"
) )
) )
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
@@ -308,7 +309,6 @@ fun Main() {
isConnected.value = true isConnected.value = true
} }
} else { } else {
// Permission is not granted, request it
Column ( Column (
modifier = Modifier.padding(24.dp), modifier = Modifier.padding(24.dp),
){ ){

View File

@@ -50,6 +50,8 @@ import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.ParcelUuid import android.os.ParcelUuid
import android.telephony.PhoneStateListener
import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
import android.view.View import android.view.View
@@ -76,9 +78,10 @@ import me.kavishdevar.aln.utils.BatteryStatus
import me.kavishdevar.aln.utils.CrossDevice import me.kavishdevar.aln.utils.CrossDevice
import me.kavishdevar.aln.utils.CrossDevicePackets import me.kavishdevar.aln.utils.CrossDevicePackets
import me.kavishdevar.aln.utils.Enums import me.kavishdevar.aln.utils.Enums
import me.kavishdevar.aln.utils.IslandWindow
import me.kavishdevar.aln.utils.LongPressPackets import me.kavishdevar.aln.utils.LongPressPackets
import me.kavishdevar.aln.utils.MediaController import me.kavishdevar.aln.utils.MediaController
import me.kavishdevar.aln.utils.Window import me.kavishdevar.aln.utils.PopupWindow
import me.kavishdevar.aln.widgets.BatteryWidget import me.kavishdevar.aln.widgets.BatteryWidget
import me.kavishdevar.aln.widgets.NoiseControlWidget import me.kavishdevar.aln.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -114,7 +117,7 @@ object ServiceManager {
// @Suppress("unused") // @Suppress("unused")
class AirPodsService : Service() { class AirPodsService : Service() {
private var macAddress = "" var macAddress = ""
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService fun getService(): AirPodsService = this@AirPodsService
@@ -126,6 +129,9 @@ class AirPodsService : Service() {
private val _packetLogsFlow = MutableStateFlow<Set<String>>(emptySet()) private val _packetLogsFlow = MutableStateFlow<Set<String>>(emptySet())
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
private lateinit var telephonyManager: TelephonyManager
private lateinit var phoneStateListener: PhoneStateListener
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
@@ -166,10 +172,25 @@ class AirPodsService : Service() {
if (popupShown) { if (popupShown) {
return return
} }
val window = Window(service.applicationContext) val popupWindow = PopupWindow(service.applicationContext)
window.open(name, batteryNotification) popupWindow.open(name, batteryNotification)
popupShown = true popupShown = true
} }
var islandOpen = false
var islandWindow: IslandWindow? = null
@SuppressLint("MissingPermission")
fun showIsland(service: Service, batteryPercentage: Int, takingOver: Boolean = false) {
Log.d("AirPodsService", "Showing island window")
islandWindow = IslandWindow(service.applicationContext)
islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this, takingOver)
}
@OptIn(ExperimentalMaterial3Api::class)
fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
@Suppress("ClassName") @Suppress("ClassName")
private object bluetoothReceiver : BroadcastReceiver() { private object bluetoothReceiver : BroadcastReceiver() {
@@ -220,23 +241,7 @@ class AirPodsService : Service() {
object BatteryChangedIntentReceiver : BroadcastReceiver() { object BatteryChangedIntentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) { override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == Intent.ACTION_BATTERY_CHANGED) { if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
val level = intent.getIntExtra("level", 0) ServiceManager.getService()?.updateBatteryWidget()
val scale = intent.getIntExtra("scale", 100)
val batteryPct = level * 100 / scale
val charging = intent.getIntExtra(
BatteryManager.EXTRA_STATUS,
-1
) == BatteryManager.BATTERY_STATUS_CHARGING
if (ServiceManager.getService()?.widgetMobileBatteryEnabled == true) {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context!!, BatteryWidget::class.java)
val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
val remoteViews = RemoteViews(context.packageName, R.layout.battery_widget)
remoteViews.setTextViewText(R.id.phone_battery_widget, "$batteryPct%")
remoteViews.setProgressBar(R.id.phone_battery_progress, 100, batteryPct, false)
appWidgetManager.updateAppWidget(widgetIds, remoteViews)
}
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try { try {
context?.unregisterReceiver(this) context?.unregisterReceiver(this)
@@ -568,13 +573,55 @@ class AirPodsService : Service() {
Log.d("AirPodsService", "Service started") Log.d("AirPodsService", "Service started")
ServiceManager.setService(this) ServiceManager.setService(this)
startForegroundNotification() startForegroundNotification()
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
audioManager,
this@AirPodsService.getSharedPreferences(
"settings",
MODE_PRIVATE
)
)
Log.d("AirPodsService", "Initializing CrossDevice") Log.d("AirPodsService", "Initializing CrossDevice")
CrossDevice.init(this) CoroutineScope(Dispatchers.IO).launch {
Log.d("AirPodsService", "CrossDevice initialized") CrossDevice.init(this@AirPodsService)
Log.d("AirPodsService", "CrossDevice initialized")
}
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
macAddress = sharedPreferences.getString("mac_address", "") ?: ""
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
phoneStateListener = object : PhoneStateListener() {
@SuppressLint("SwitchIntDef")
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber)
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
}
}
}
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
if (sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) {
widgetMobileBatteryEnabled = true
val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
BatteryChangedIntentReceiver,
batteryChangedIntentFilter,
RECEIVER_EXPORTED
)
} else {
registerReceiver(BatteryChangedIntentReceiver, batteryChangedIntentFilter)
}
}
val serviceIntentFilter = IntentFilter().apply { val serviceIntentFilter = IntentFilter().apply {
addAction("android.bluetooth.device.action.ACL_CONNECTED") addAction("android.bluetooth.device.action.ACL_CONNECTED")
addAction("android.bluetooth.device.action.ACL_DISCONNECTED") addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
@@ -605,13 +652,16 @@ class AirPodsService : Service() {
putString("name", name) putString("name", name)
} }
} }
Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString()) Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.checkAirPodsConnectionStatus()) { if (!CrossDevice.isAvailable) {
Log.d("AirPodsService", "$name connected") Log.d("AirPodsService", "$name connected")
showPopup(this@AirPodsService, name.toString()) showPopup(this@AirPodsService, name.toString())
connectToSocket(device!!) connectToSocket(device!!)
isConnectedLocally = true isConnectedLocally = true
macAddress = device!!.address macAddress = device!!.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
updateNotificationContent( updateNotificationContent(
true, true,
name.toString(), name.toString(),
@@ -626,7 +676,30 @@ class AirPodsService : Service() {
} }
} }
} }
val showIslandReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.aln.cross_device_island") {
showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
} else if (intent?.action == AirPodsNotifications.Companion.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
val showIslandIntentFilter = IntentFilter().apply {
addAction("me.kavishdevar.aln.cross_device_island")
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
} else {
registerReceiver(showIslandReceiver, showIslandIntentFilter)
}
val deviceIntentFilter = IntentFilter().apply { val deviceIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED) addAction(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
@@ -641,11 +714,6 @@ class AirPodsService : Service() {
registerReceiver(bluetoothReceiver, serviceIntentFilter) registerReceiver(bluetoothReceiver, serviceIntentFilter)
} }
widgetMobileBatteryEnabled = getSharedPreferences("settings", MODE_PRIVATE).getBoolean(
"show_phone_battery_in_widget",
true
)
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
if (bluetoothAdapter.isEnabled) { if (bluetoothAdapter.isEnabled) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -682,8 +750,12 @@ class AirPodsService : Service() {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) { if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.checkAirPodsConnectionStatus()) { if (!CrossDevice.isAvailable) {
connectToSocket(device) connectToSocket(device)
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
} }
this@AirPodsService.sendBroadcast( this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED) Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
@@ -720,12 +792,27 @@ class AirPodsService : Service() {
} }
} }
@SuppressLint("MissingPermission")
fun takeOver() {
Log.d("AirPodsService", "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d("AirPodsService", macAddress)
device = getSystemService<BluetoothManager>(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
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)
}
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) { fun connectToSocket(device: BluetoothDevice) {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (isConnectedLocally != true) { if (isConnectedLocally != true && !CrossDevice.isAvailable) {
try { try {
socket = HiddenApiBypass.newInstance( socket = HiddenApiBypass.newInstance(
BluetoothSocket::class.java, BluetoothSocket::class.java,
@@ -799,15 +886,6 @@ class AirPodsService : Service() {
) )
while (socket.isConnected == true) { while (socket.isConnected == true) {
socket.let { socket.let {
val audioManager =
this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
audioManager,
this@AirPodsService.getSharedPreferences(
"settings",
MODE_PRIVATE
)
)
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer) val bytesRead = it.inputStream.read(buffer)
var data: ByteArray = byteArrayOf() var data: ByteArray = byteArrayOf()
@@ -852,11 +930,16 @@ class AirPodsService : Service() {
} else { } else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte() data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
} }
val newInEarData = listOf( val newInEarData = listOf(
data[0] == 0x00.toByte(), data[0] == 0x00.toByte(),
data[1] == 0x00.toByte() data[1] == 0x00.toByte()
) )
if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
}
if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
islandWindow?.close()
}
if (newInEarData.contains(true) && inEarData == listOf( if (newInEarData.contains(true) && inEarData == listOf(
false, false,
false false
@@ -1296,6 +1379,9 @@ class AirPodsService : Service() {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
if (MediaController.pausedForCrossDevice) {
MediaController.sendPlay()
}
} }
} }
} }
@@ -1521,6 +1607,7 @@ class AirPodsService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
super.onDestroy() super.onDestroy()
} }
} }

View File

@@ -1,3 +1,22 @@
/*
* 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.utils package me.kavishdevar.aln.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -10,6 +29,7 @@ import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
@@ -44,25 +64,28 @@ object CrossDevice {
var batteryBytes: ByteArray = byteArrayOf() var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf() var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
private const val packetLogKey = "packet_log" private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false)
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun init(context: Context) { fun init(context: Context) {
Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice") CoroutineScope(Dispatchers.IO).launch {
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE) Log.d("CrossDevice", "Initializing CrossDevice")
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
startAdvertising() this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startServer() startAdvertising()
initialized = true startServer()
initialized = true
}
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun startServer() { private fun startServer() {
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("AirPodsQuickSwitchService", "Server started")
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) { while (serverSocket != null) {
try { try {
val socket = serverSocket!!.accept() val socket = serverSocket!!.accept()
@@ -76,29 +99,31 @@ object CrossDevice {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun startAdvertising() { private fun startAdvertising() {
val settings = AdvertiseSettings.Builder() CoroutineScope(Dispatchers.IO).launch {
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) val settings = AdvertiseSettings.Builder()
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH) .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true) .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.build() .setConnectable(true)
.build()
val data = AdvertiseData.Builder() val data = AdvertiseData.Builder()
.setIncludeDeviceName(true) .setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray()) .addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid)) .addServiceUuid(ParcelUuid(uuid))
.build() .build()
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback) bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
Log.d("AirPodsQuickSwitchService", "BLE Advertising started") Log.d("CrossDevice", "BLE Advertising started")
}
} }
private val advertiseCallback = object : AdvertiseCallback() { private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d("AirPodsQuickSwitchService", "BLE Advertising started successfully") Log.d("CrossDevice", "BLE Advertising started successfully")
} }
override fun onStartFailure(errorCode: Int) { override fun onStartFailure(errorCode: Int) {
Log.e("AirPodsQuickSwitchService", "BLE Advertising failed with error code: $errorCode") Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
} }
} }
@@ -113,9 +138,9 @@ object CrossDevice {
} }
fun sendReceivedPacket(packet: ByteArray) { fun sendReceivedPacket(packet: ByteArray) {
Log.d("AirPodsQuickSwitchService", "Sending packet to remote device") Log.d("CrossDevice", "Sending packet to remote device")
if (clientSocket == null) { if (clientSocket == null || clientSocket!!.outputStream != null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null") Log.d("CrossDevice", "Client socket is null")
return return
} }
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
@@ -124,14 +149,14 @@ object CrossDevice {
private fun logPacket(packet: ByteArray, source: String) { private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) } val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex" val logEntry = "$source: $packetHex"
val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf() val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry) logs.add(logEntry)
sharedPreferences.edit().putStringSet(packetLogKey, logs).apply() sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply()
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) { private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("AirPodsQuickSwitchService", "Client connected") Log.d("CrossDevice", "Client connected")
clientSocket = socket clientSocket = socket
val inputStream = socket.inputStream val inputStream = socket.inputStream
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
@@ -141,7 +166,7 @@ object CrossDevice {
bytes = inputStream.read(buffer) bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes) val packet = buffer.copyOf(bytes)
logPacket(packet, "Relay") logPacket(packet, "Relay")
Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}") Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) { if (bytes == -1) {
break break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
@@ -153,36 +178,49 @@ object CrossDevice {
isAvailable = false isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}") Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes) sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received ANC request") Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes) sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
Log.d("AirPodsQuickSwitchService", "Received connection status request") Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet) sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
} } else {
else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply() sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray() var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("AirPodsQuickSwitchService", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket)}") Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}") Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) { if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) } val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex) ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) { } else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket) ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("AirPodsQuickSwitchService", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}") Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget() ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.sendBatteryBroadcast() ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification() ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) { } else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket) ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast() ServiceManager.getService()?.sendANCBroadcast()
ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket ancBytes = trimmedPacket
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
val newEarDetectionStatus = listOf(
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.aln.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
} }
} }
} }
@@ -190,31 +228,13 @@ object CrossDevice {
} }
fun sendRemotePacket(byteArray: ByteArray) { fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null) { if (clientSocket == null || clientSocket!!.outputStream == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null") Log.d("CrossDevice", "Client socket is null")
return return
} }
clientSocket?.outputStream?.write(byteArray) clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush() clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent") logPacket(byteArray, "Sent")
Log.d("AirPodsQuickSwitchService", "Sent packet to remote device") Log.d("CrossDevice", "Sent packet to remote device")
}
fun checkAirPodsConnectionStatus(): Boolean {
Log.d("AirPodsQuickSwitchService", "Checking AirPods connection status")
if (clientSocket == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null - linux probably not connected.")
return false
}
return try {
clientSocket?.outputStream?.write(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)
val buffer = ByteArray(1024)
val bytes = clientSocket?.inputStream?.read(buffer) ?: -1
val packet = buffer.copyOf(bytes)
packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} catch (e: IOException) {
Log.e("AirPodsQuickSwitchService", "Error checking connection status", e)
false
}
} }
} }

View File

@@ -0,0 +1,148 @@
/*
* 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.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.PixelFormat
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log.e
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.animation.AnticipateOvershootInterpolator
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
class IslandWindow(context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams")
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
private var isClosing = false
val isVisible: Boolean
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
val params = WindowManager.LayoutParams(
width,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
islandView.visibility = View.VISIBLE
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
islandView.findViewById<TextView>(R.id.island_device_name).text = name
islandView.setOnClickListener {
ServiceManager.getService()?.startMainActivity()
close()
}
if (takingOver) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
}
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
batteryProgressBar.progress = batteryPercentage
batteryProgressBar.isIndeterminate = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}")
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
videoView.start()
}
windowManager.addView(islandView, params)
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
Handler(Looper.getMainLooper()).postDelayed({
close()
}, 4500)
}
fun close() {
try {
if (isClosing) return
isClosing = true
ServiceManager.getService()?.islandOpen = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
videoView.stopPlayback()
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
islandView.visibility = View.GONE
try {
windowManager.removeView(islandView)
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
}
})
start()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -25,6 +25,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import me.kavishdevar.aln.services.ServiceManager
object MediaController { object MediaController {
private var initialVolume: Int? = null private var initialVolume: Int? = null
@@ -34,14 +35,19 @@ object MediaController {
private lateinit var sharedPreferences: SharedPreferences private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
var pausedForCrossDevice = false
private var relativeVolume: Boolean = false private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12 private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessPauseMusic: Boolean = false private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
if (this::audioManager.isInitialized) {
return
}
this.audioManager = audioManager this.audioManager = audioManager
this.sharedPreferences = sharedPreferences this.sharedPreferences = sharedPreferences
Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12) conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
@@ -74,6 +80,14 @@ object MediaController {
userPlayedTheMedia = audioManager.isMusicActive userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something. }, 7) // i have no idea why android sends an event a hundred times after the user does something.
} }
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
if (ServiceManager.getService()?.isConnectedLocally == false) {
sendPause(true)
pausedForCrossDevice = true
}
ServiceManager.getService()?.takeOver()
}
} }
} }

View File

@@ -44,7 +44,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.aln.R import me.kavishdevar.aln.R
@SuppressLint("InflateParams", "ClickableViewAccessibility") @SuppressLint("InflateParams", "ClickableViewAccessibility")
class Window (context: Context) { class PopupWindow(context: Context) {
private val mView: View private val mView: View
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -56,13 +56,12 @@ class Window (context: Context) {
gravity = Gravity.BOTTOM gravity = Gravity.BOTTOM
dimAmount = 0.3f dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_FULLSCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_DIM_BEHIND or WindowManager.LayoutParams.FLAG_DIM_BEHIND or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
} }
private val mWindowManager: WindowManager private val mWindowManager: WindowManager
init { init {
@@ -72,14 +71,13 @@ class Window (context: Context) {
mParams.y = 0 mParams.y = 0
mParams.gravity = Gravity.BOTTOM mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener(View.OnClickListener { mView.setOnClickListener {
close() close()
}) }
mView.findViewById<ImageButton>(R.id.close_button) mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
.setOnClickListener { close()
close() }
}
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout) val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
ll.setOnClickListener { ll.setOnClickListener {
@@ -88,11 +86,11 @@ class Window (context: Context) {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
mView.setOnTouchListener { _, event -> mView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) { if (event.action == MotionEvent.ACTION_DOWN) {
@@ -116,7 +114,6 @@ class Window (context: Context) {
try { try {
if (mView.windowToken == null) { if (mView.windowToken == null) {
if (mView.parent == null) { if (mView.parent == null) {
// Add the view initially off-screen
mWindowManager.addView(mView, mParams) mWindowManager.addView(mView, mParams)
mView.findViewById<TextView>(R.id.name).text = name mView.findViewById<TextView>(R.id.name).text = name
val vid = mView.findViewById<VideoView>(R.id.video) val vid = mView.findViewById<VideoView>(R.id.video)
@@ -143,14 +140,13 @@ class Window (context: Context) {
"\uDBC3\uDE6C ${it.level}%" "\uDBC3\uDE6C ${it.level}%"
} ?: "" } ?: ""
// Slide-up animation
val displayMetrics = mView.context.resources.displayMetrics val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels val screenHeight = displayMetrics.heightPixels
mView.translationY = screenHeight.toFloat() // Start below the screen mView.translationY = screenHeight.toFloat()
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply { ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
duration = 500 // Animation duration in milliseconds duration = 500
interpolator = DecelerateInterpolator() // Smooth deceleration interpolator = DecelerateInterpolator()
start() start()
} }
@@ -168,8 +164,8 @@ class Window (context: Context) {
fun close() { fun close() {
try { try {
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply { ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500 // Animation duration in milliseconds duration = 500
interpolator = AccelerateInterpolator() // Smooth acceleration interpolator = AccelerateInterpolator()
addListener(object : AnimatorListenerAdapter() { addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) { override fun onAnimationEnd(animation: Animator) {
try { try {

View File

@@ -0,0 +1,9 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000"/>
<corners android:radius="56dp"/>
<padding android:left="4dp" android:top="4dp" android:right="4dp" android:bottom="4dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="#0f4524" />
</shape>
</rotate>

View File

@@ -0,0 +1,11 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp" >
<solid android:color="#1ceb72" />
</shape>
</rotate>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/island_window_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:layout_weight="0.95"
android:background="@drawable/island_background"
android:elevation="4dp"
android:gravity="center"
android:minHeight="115dp"
android:orientation="horizontal"
android:outlineAmbientShadowColor="#4EFFFFFF"
android:outlineSpotShadowColor="#4EFFFFFF"
android:padding="8dp">
<VideoView
android:id="@+id/island_video_view"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="0dp"
android:layout_weight="1"
android:gravity="bottom"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/island_connected_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:textSize="16sp" />
<TextView
android:id="@+id/island_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="AirPods Pro"
android:textColor="@color/white"
android:textSize="24sp"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
tools:ignore="HardcodedText" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center">
<ProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/island_battery_background" />
<ProgressBar
android:id="@+id/island_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/island_battery_progress" />
<TextView
android:id="@+id/island_battery_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="50%"
android:textColor="#1ceb72"
android:textSize="16sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>

Binary file not shown.

View File

@@ -1,45 +1,48 @@
<resources> <resources>
<string name="app_name" translatable="false">ALN</string> <string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string> <string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string> <string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string> <string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string> <string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string> <string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string> <string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string> <string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string> <string name="buds">Buds</string>
<string name="case_alt">Case</string> <string name="case_alt">Case</string>
<string name="test">Test</string> <string name="test">Test</string>
<string name="name">Name</string> <string name="name">Name</string>
<string name="noise_control">Noise Control</string> <string name="noise_control">Noise Control</string>
<string name="off">Off</string> <string name="off">Off</string>
<string name="transparency">Transparency</string> <string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string> <string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string> <string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string> <string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string> <string name="left">Left</string>
<string name="right">Right</string> <string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string> <string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string> <string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string> <string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string> <string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string> <string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string> <string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string> <string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string> <string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string> <string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string> <string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string> <string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string> <string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string> <string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="app_settings">App Settings</string> <string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string> <string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string> <string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string> <string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string> <string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string> <string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="appwidget_text">EXAMPLE</string> <string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string> <string name="add_widget">Add widget</string>
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string> <string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
<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>
</resources> </resources>

View File

@@ -1,53 +0,0 @@
#pragma once
#include <QObject>
#include <QSystemTrayIcon>
#include <QMenu>
#include <QBluetoothDeviceDiscoveryAgent>
#include <QBluetoothSocket>
#include <QDBusInterface>
#include "BluetoothHandler.h"
class AirPodsTrayApp : public QObject {
Q_OBJECT
public:
AirPodsTrayApp();
public slots:
void connectToDevice(const QString &address);
void showAvailableDevices();
void setNoiseControlMode(int mode);
void setConversationalAwareness(bool enabled);
void updateNoiseControlMenu(int mode);
void updateBatteryTooltip(const QString &status);
void updateTrayIcon(const QString &status);
void handleEarDetection(const QString &status);
private slots:
void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason);
void onDeviceDiscovered(const QBluetoothDeviceInfo &device);
void onDiscoveryFinished();
void onDeviceConnected(const QBluetoothAddress &address);
void onDeviceDisconnected(const QBluetoothAddress &address);
void onPhoneDataReceived();
signals:
void noiseControlModeChanged(int mode);
void earDetectionStatusChanged(const QString &status);
void batteryStatusChanged(const QString &status);
private:
void initializeMprisInterface();
void connectToPhone();
void relayPacketToPhone(const QByteArray &packet);
void handlePhonePacket(const QByteArray &packet);
QSystemTrayIcon *trayIcon;
QMenu *trayMenu;
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
QBluetoothSocket *socket = nullptr;
QBluetoothSocket *phoneSocket = nullptr;
QDBusInterface *mprisInterface;
QString connectedDeviceMacAddress;
};

View File

@@ -1,109 +0,0 @@
#include "BluetoothHandler.h"
#include "PacketDefinitions.h"
#include <QLoggingCategory>
Q_LOGGING_CATEGORY(bluetoothHandler, "bluetoothHandler")
#define LOG_INFO(msg) qCInfo(bluetoothHandler) << "\033[32m" << msg << "\033[0m"
#define LOG_WARN(msg) qCWarning(bluetoothHandler) << "\033[33m" << msg << "\033[0m"
#define LOG_ERROR(msg) qCCritical(bluetoothHandler) << "\033[31m" << msg << "\033[0m"
#define LOG_DEBUG(msg) qCDebug(bluetoothHandler) << "\033[34m" << msg << "\033[0m"
BluetoothHandler::BluetoothHandler() {
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &BluetoothHandler::onDeviceDiscovered);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &BluetoothHandler::onDiscoveryFinished);
discoveryAgent->start();
LOG_INFO("BluetoothHandler initialized and started device discovery");
}
void BluetoothHandler::connectToDevice(const QBluetoothDeviceInfo &device) {
if (socket && socket->isOpen() && socket->peerAddress() == device.address()) {
LOG_INFO("Already connected to the device: " << device.name());
return;
}
LOG_INFO("Connecting to device: " << device.name());
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
LOG_INFO("Connected to device, sending initial packets");
discoveryAgent->stop();
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
qint64 bytesWritten = localSocket->write(handshakePacket);
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
phoneSocket->write(airpodsConnectedPacket);
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
LOG_INFO("Bytes written: " << bytes);
if (bytes > 0) {
static int step = 0;
switch (step) {
case 0:
localSocket->write(setSpecificFeaturesPacket);
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
step++;
break;
case 1:
localSocket->write(requestNotificationsPacket);
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
step++;
break;
}
}
});
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
QByteArray data = localSocket->readAll();
LOG_DEBUG("Data received: " << data.toHex());
parseData(data);
relayPacketToPhone(data);
});
});
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
});
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
socket = localSocket;
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
}
void BluetoothHandler::parseData(const QByteArray &data) {
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
int mode = data[7] - 1;
LOG_INFO("Noise control mode: " << mode);
if (mode >= 0 && mode <= 3) {
emit noiseControlModeChanged(mode);
} else {
LOG_ERROR("Invalid noise control mode value received: " << mode);
}
} else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
bool primaryInEar = data[6] == 0x00;
bool secondaryInEar = data[7] == 0x00;
QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
.arg(primaryInEar ? "In Ear" : "Out of Ear")
.arg(secondaryInEar ? "In Ear" : "Out of Ear");
LOG_INFO("Ear detection status: " << earDetectionStatus);
emit earDetectionStatusChanged(earDetectionStatus);
} else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
int leftLevel = data[9];
int rightLevel = data[14];
int caseLevel = data[19];
QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
.arg(leftLevel)
.arg(rightLevel)
.arg(caseLevel);
LOG_INFO("Battery status: " << batteryStatus);
emit batteryStatusChanged(batteryStatus);
} else if (data.size() == 10 &&

View File

@@ -1,23 +0,0 @@
#pragma once
#include <QBluetoothDeviceInfo>
#include <QBluetoothSocket>
#include <QBluetoothDeviceDiscoveryAgent>
class BluetoothHandler : public QObject {
Q_OBJECT
public:
BluetoothHandler();
void connectToDevice(const QBluetoothDeviceInfo &device);
void parseData(const QByteArray &data);
signals:
void noiseControlModeChanged(int mode);
void earDetectionStatusChanged(const QString &status);
void batteryStatusChanged(const QString &status);
private:
QBluetoothSocket *socket = nullptr;
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
};

View File

@@ -10,9 +10,6 @@ qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(applinux qt_add_executable(applinux
main.cpp main.cpp
AirPodsTrayApp.cpp
BluetoothHandler.cpp
PacketDefinitions.cpp
) )
qt_add_qml_module(applinux qt_add_qml_module(applinux

View File

@@ -21,12 +21,14 @@ ApplicationWindow {
text: "Battery Status: " text: "Battery Status: "
id: batteryStatus id: batteryStatus
objectName: "batteryStatus" objectName: "batteryStatus"
color: "#ffffff"
} }
Text { Text {
text: "Ear Detection Status: " text: "Ear Detection Status: "
id: earDetectionStatus id: earDetectionStatus
objectName: "earDetectionStatus" objectName: "earDetectionStatus"
color: "#ffffff"
} }
ComboBox { ComboBox {

View File

@@ -39,7 +39,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0" #define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
// Define Manufacturer Specific Data Identifier
#define MANUFACTURER_ID 0x1234 #define MANUFACTURER_ID 0x1234
#define MANUFACTURER_DATA "ALN_AirPods" #define MANUFACTURER_DATA "ALN_AirPods"
@@ -92,7 +91,7 @@ public:
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated); connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
discoveryAgent = new QBluetoothDeviceDiscoveryAgent(); discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
discoveryAgent->setLowEnergyDiscoveryTimeout(5000); discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
@@ -114,12 +113,10 @@ public:
initializeMprisInterface(); initializeMprisInterface();
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived); connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
// After starting discovery, check if service record exists
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1"); QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
QDBusReply<QVariant> reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a")); QDBusReply<QVariant> reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a"));
if (reply.isValid()) { if (reply.isValid()) {
LOG_INFO("Service record found, proceeding with connection"); LOG_INFO("Service record found, proceeding with connection");
// Proceed with existing connection logic
} else { } else {
LOG_WARN("Service record not found, waiting for BLE broadcast"); LOG_WARN("Service record not found, waiting for BLE broadcast");
} }
@@ -236,7 +233,7 @@ public slots:
bool secondaryInEar = parts[1].contains("In Ear"); bool secondaryInEar = parts[1].contains("In Ear");
if (primaryInEar && secondaryInEar) { if (primaryInEar && secondaryInEar) {
if (wasPausedByApp) { if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
QProcess::execute("playerctl", QStringList() << "play"); QProcess::execute("playerctl", QStringList() << "play");
LOG_INFO("Resumed playback via Playerctl"); LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false; wasPausedByApp = false;
@@ -245,15 +242,17 @@ public slots:
activateA2dpProfile(); activateA2dpProfile();
} else { } else {
LOG_INFO("At least one AirPod is out of ear"); LOG_INFO("At least one AirPod is out of ear");
QProcess process; if (isActiveOutputDeviceAirPods()) {
process.start("playerctl", QStringList() << "status"); QProcess process;
process.waitForFinished(); process.start("playerctl", QStringList() << "status");
QString playbackStatus = process.readAllStandardOutput().trimmed(); process.waitForFinished();
LOG_DEBUG("Playback status: " << playbackStatus); QString playbackStatus = process.readAllStandardOutput().trimmed();
if (playbackStatus == "Playing") { LOG_DEBUG("Playback status: " << playbackStatus);
QProcess::execute("playerctl", QStringList() << "pause"); if (playbackStatus == "Playing") {
LOG_INFO("Paused playback via Playerctl"); QProcess::execute("playerctl", QStringList() << "pause");
wasPausedByApp = true; LOG_INFO("Paused playback via Playerctl");
wasPausedByApp = true;
}
} }
if (!primaryInEar && !secondaryInEar) { if (!primaryInEar && !secondaryInEar) {
removeAudioOutputDevice(); removeAudioOutputDevice();
@@ -308,7 +307,6 @@ public slots:
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID); QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
if (manufacturerData.startsWith(MANUFACTURER_DATA)) { if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
LOG_INFO("Detected AirPods via BLE manufacturer data"); LOG_INFO("Detected AirPods via BLE manufacturer data");
// Initiate RFComm connection
connectToDevice(device.address().toString()); connectToDevice(device.address().toString());
} }
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")"); LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
@@ -320,7 +318,6 @@ public slots:
void onDiscoveryFinished() { void onDiscoveryFinished() {
LOG_INFO("Device discovery finished"); LOG_INFO("Device discovery finished");
// Restart discovery to continuously listen for broadcasts
discoveryAgent->start(); discoveryAgent->start();
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices(); const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
for (const QBluetoothDeviceInfo &device : discoveredDevices) { for (const QBluetoothDeviceInfo &device : discoveredDevices) {
@@ -360,76 +357,67 @@ public slots:
return; return;
} }
LOG_INFO("Checking connection status with phone before connecting to device: " << device.name()); LOG_INFO("Connecting to device: " << device.name());
QByteArray connectionStatusRequest = QByteArray::fromHex("00020003"); QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::RfcommProtocol);
if (phoneSocket && phoneSocket->isOpen()) { connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
phoneSocket->write(connectionStatusRequest); LOG_INFO("Connected to device, sending initial packets");
LOG_DEBUG("Connection status request packet written: " << connectionStatusRequest.toHex()); discoveryAgent->stop();
connect(phoneSocket, &QBluetoothSocket::readyRead, this, [this, device]() {
QByteArray data = phoneSocket->read(4);
LOG_DEBUG("Data received from phone: " << data.toHex());
if (data == QByteArray::fromHex("00010001")) {
LOG_INFO("AirPods are already connected");
disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
} else if (data == QByteArray::fromHex("00010000")) {
LOG_INFO("AirPods are disconnected, proceeding with connection");
disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() { QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
LOG_INFO("Connected to device, sending initial packets"); QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
discoveryAgent->stop();
QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000"); qint64 bytesWritten = localSocket->write(handshakePacket);
QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000"); LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff"); localSocket->write(setSpecificFeaturesPacket);
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
qint64 bytesWritten = localSocket->write(handshakePacket); localSocket->write(requestNotificationsPacket);
LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten); LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001"); LOG_INFO("Bytes written: " << bytes);
phoneSocket->write(airpodsConnectedPacket); if (bytes > 0) {
LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex()); static int step = 0;
switch (step) {
connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) { case 0:
LOG_INFO("Bytes written: " << bytes); localSocket->write(setSpecificFeaturesPacket);
if (bytes > 0) { LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
static int step = 0; step++;
switch (step) { break;
case 0: case 1:
localSocket->write(setSpecificFeaturesPacket); localSocket->write(requestNotificationsPacket);
LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex()); LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
step++; step++;
break; break;
case 1: }
localSocket->write(requestNotificationsPacket);
LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
step++;
break;
}
}
});
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
QByteArray data = localSocket->readAll();
LOG_DEBUG("Data received: " << data.toHex());
parseData(data);
relayPacketToPhone(data);
});
});
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
});
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
socket = localSocket;
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
} }
}); });
} else {
LOG_ERROR("Phone socket is not open, cannot send connection status request"); 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));
});
QTimer::singleShot(500, this, [localSocket, setSpecificFeaturesPacket, requestNotificationsPacket]() {
if (localSocket->isOpen()) {
localSocket->write(setSpecificFeaturesPacket);
LOG_DEBUG("Resent set specific features packet: " << setSpecificFeaturesPacket.toHex());
localSocket->write(requestNotificationsPacket);
LOG_DEBUG("Resent request notifications packet: " << requestNotificationsPacket.toHex());
} else {
LOG_WARN("Socket is not open, cannot resend packets");
}
});
});
connect(localSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
});
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
socket = localSocket;
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
} }
void parseData(const QByteArray &data) { void parseData(const QByteArray &data) {
@@ -474,7 +462,7 @@ public slots:
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled")); LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
if (lowered) { if (lowered) {
if (initialVolume == -1) { if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
QProcess process; QProcess process;
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@"); process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
process.waitForFinished(); process.waitForFinished();
@@ -492,7 +480,7 @@ public slots:
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume * 0.20) + "%"); QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume * 0.20) + "%");
LOG_INFO("Volume lowered to 0.20 of initial which is " << initialVolume * 0.20 << "%"); LOG_INFO("Volume lowered to 0.20 of initial which is " << initialVolume * 0.20 << "%");
} else { } else {
if (initialVolume != -1) { if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%"); QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
LOG_INFO("Volume restored to " << initialVolume << "%"); LOG_INFO("Volume restored to " << initialVolume << "%");
initialVolume = -1; initialVolume = -1;
@@ -500,6 +488,15 @@ public slots:
} }
} }
bool isActiveOutputDeviceAirPods() {
QProcess process;
process.start("pactl", QStringList() << "get-default-sink");
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Default sink: " << output);
return output.contains("bluez_card." + connectedDeviceMacAddress);
}
void initializeMprisInterface() { void initializeMprisInterface() {
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames(); QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
QString mprisService; QString mprisService;
@@ -560,7 +557,7 @@ public slots:
void handlePhonePacket(const QByteArray &packet) { void handlePhonePacket(const QByteArray &packet) {
if (packet.startsWith(QByteArray::fromHex("00040001"))) { if (packet.startsWith(QByteArray::fromHex("00040001"))) {
QByteArray airpodsPacket = packet.mid(4); // Remove the header QByteArray airpodsPacket = packet.mid(4);
if (socket && socket->isOpen()) { if (socket && socket->isOpen()) {
socket->write(airpodsPacket); socket->write(airpodsPacket);
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex()); LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
@@ -569,15 +566,24 @@ public slots:
} }
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) { } else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
LOG_INFO("AirPods connected"); LOG_INFO("AirPods connected");
// Handle AirPods connected
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) { } else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
LOG_INFO("AirPods disconnected"); LOG_INFO("AirPods disconnected");
// Handle AirPods disconnected
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) { } else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
LOG_INFO("Connection status request received"); LOG_INFO("Connection status request received");
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000"); QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
phoneSocket->write(response); phoneSocket->write(response);
LOG_DEBUG("Sent connection status response: " << response.toHex()); LOG_DEBUG("Sent connection status response: " << response.toHex());
} else if (packet.startsWith(QByteArray::fromHex("00020000"))) {
LOG_INFO("Disconnect request received");
if (socket && socket->isOpen()) {
socket->close();
LOG_INFO("Disconnected from AirPods");
QProcess process;
process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
}
} else { } else {
if (socket && socket->isOpen()) { if (socket && socket->isOpen()) {
socket->write(packet); socket->write(packet);
@@ -591,7 +597,37 @@ public slots:
void onPhoneDataReceived() { void onPhoneDataReceived() {
QByteArray data = phoneSocket->readAll(); QByteArray data = phoneSocket->readAll();
LOG_DEBUG("Data received from phone: " << data.toHex()); LOG_DEBUG("Data received from phone: " << data.toHex());
handlePhonePacket(data); QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
}
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();
}
});
playerctlProcess->start("playerctl", QStringList() << "metadata" << "--follow" << "status");
}
bool isPhoneConnected() {
return phoneSocket && phoneSocket->isOpen();
}
void connectToAirPods() {
QBluetoothLocalDevice localDevice;
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
for (const QBluetoothAddress &address : connectedDevices) {
QBluetoothDeviceInfo device(address, "", 0);
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
connectToDevice(device);
return;
}
}
LOG_WARN("AirPods not found among connected devices");
} }
signals: signals:
@@ -673,6 +709,8 @@ int main(int argc, char *argv[]) {
} }
}); });
trayApp.followMediaChanges();
return app.exec(); return app.exec();
} }