try to add some cross-device stuff

This commit is contained in:
Kavish Devar
2025-01-20 04:13:18 +05:30
parent 7cac2b037f
commit 7a06f3055c
6 changed files with 178 additions and 111 deletions

View File

@@ -22,10 +22,12 @@ import android.annotation.SuppressLint
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
@@ -147,6 +149,16 @@ fun Main() {
}
}
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") {
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)

View File

@@ -18,6 +18,7 @@
package me.kavishdevar.aln.services
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
@@ -47,6 +48,7 @@ import android.os.Looper
import android.os.ParcelUuid
import android.util.Log
import android.widget.RemoteViews
import androidx.annotation.RequiresPermission
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
@@ -64,6 +66,7 @@ import me.kavishdevar.aln.utils.Battery
import me.kavishdevar.aln.utils.BatteryComponent
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.LongPressPackets
import me.kavishdevar.aln.utils.MediaController
@@ -163,11 +166,15 @@ class AirPodsService: Service() {
return
}
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (bluetoothDevice.uuids.contains(uuid)) {
val intent = Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
bluetoothDevice.fetchUuidsWithSdp()
if (bluetoothDevice.uuids != null) {
if (bluetoothDevice.uuids.contains(uuid)) {
val intent =
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
}
}
}
}
@@ -239,6 +246,28 @@ class AirPodsService: Service() {
}
}
fun sendANCBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
putExtra("data", ancNotification.status)
})
}
fun sendBatteryBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
})
}
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
fun sendBatteryNotification() {
updateNotificationContent(
true,
getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name),
batteryNotification.getBattery()
)
}
fun updateBatteryWidget() {
val appWidgetManager = AppWidgetManager.getInstance(this)
val componentName = ComponentName(this, BatteryWidget::class.java)
@@ -791,20 +820,24 @@ class AirPodsService: Service() {
fun sendPacket(packet: String) {
val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
if (!isConnectedLocally && CrossDevice.isAvailable) {
CrossDevice.sendRemotePacket(fromHex.toByteArray())
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray())
return
}
socket.outputStream?.write(fromHex.toByteArray())
socket.outputStream?.flush()
if (this::socket.isInitialized) {
socket.outputStream?.write(fromHex.toByteArray())
socket.outputStream?.flush()
}
}
fun sendPacket(packet: ByteArray) {
if (!isConnectedLocally && CrossDevice.isAvailable) {
CrossDevice.sendRemotePacket(packet)
CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
return
}
socket.outputStream?.write(packet)
socket.outputStream?.flush()
if (this::socket.isInitialized) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
}
}
fun setANCMode(mode: Int) {
@@ -823,37 +856,31 @@ class AirPodsService: Service() {
sendPacket(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
}
}
socket.outputStream?.flush()
}
fun setCAEnabled(enabled: Boolean) {
sendPacket(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
socket.outputStream?.flush()
}
fun setOffListeningMode(enabled: Boolean) {
sendPacket(byteArrayOf(0x04, 0x00 ,0x04, 0x00, 0x09, 0x00, 0x34, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00))
socket.outputStream?.flush()
}
fun setAdaptiveStrength(strength: Int) {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setPressSpeed(speed: Int) {
// 0x00 = default, 0x01 = slower, 0x02 = slowest
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x17, speed.toByte(), 0x00, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setPressAndHoldDuration(speed: Int) {
// 0 - default, 1 - slower, 2 - slowest
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, speed.toByte(), 0x00, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setVolumeSwipeSpeed(speed: Int) {
@@ -861,25 +888,21 @@ class AirPodsService: Service() {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x23, speed.toByte(), 0x00, 0x00, 0x00)
Log.d("AirPodsService", "Setting volume swipe speed to $speed by packet ${bytes.joinToString(" ") { "%02X".format(it) }}")
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setNoiseCancellationWithOnePod(enabled: Boolean) {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1B, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setVolumeControl(enabled: Boolean) {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x25, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setToneVolume(volume: Int) {
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00)
sendPacket(bytes)
socket.outputStream?.flush()
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
@@ -892,7 +915,6 @@ class AirPodsService: Service() {
fun setCaseChargingSounds(enabled: Boolean) {
val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01)
sendPacket(bytes)
socket.outputStream?.flush()
}
fun setEarDetection(enabled: Boolean) {

View File

@@ -6,6 +6,7 @@ import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -23,6 +24,7 @@ enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
}
object CrossDevice {
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
private var serverSocket: BluetoothServerSocket? = null
@@ -31,10 +33,12 @@ object CrossDevice {
var isAvailable: Boolean = false // set to true when airpods are connected to another device
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences
@SuppressLint("MissingPermission")
fun init(context: Context) {
Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
startServer()
}
@@ -57,7 +61,8 @@ object CrossDevice {
fun setAirPodsConnected(connected: Boolean) {
if (connected) {
isAvailable = true
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} else {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
@@ -69,7 +74,9 @@ object CrossDevice {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
}
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("AirPodsQuickSwitchService", "Client connected")
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
@@ -78,14 +85,17 @@ object CrossDevice {
while (true) {
bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes)
Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
ServiceManager.getService()?.disconnect()
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("AirPodsQuickSwitchService", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
@@ -94,16 +104,32 @@ object CrossDevice {
sendRemotePacket(ancBytes)
}
else {
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = packet.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(packet) == true) {
batteryBytes = packet
ServiceManager.getService()?.batteryNotification?.setBattery(packet)
ServiceManager.getService()?.updateBatteryWidget()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(packet) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(packet)
ancBytes = packet
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
// the AIRPODS_CONNECTED wasn't sent before
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
val 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(
"AirPodsQuickSwitchService",
"Relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}"
)
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("AirPodsQuickSwitchService", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
ancBytes = trimmedPacket
}
}
}
}
@@ -112,6 +138,7 @@ object CrossDevice {
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null) {
Log.d("AirPodsQuickSwitchService", "Client socket is null")
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()

View File

@@ -22,6 +22,7 @@
package me.kavishdevar.aln.utils
import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) {
@@ -133,6 +134,9 @@ class AirPodsNotifications {
}
fun setStatus(data: ByteArray) {
if (data.size != 11) {
return
}
status = data[7].toInt()
}
@@ -154,13 +158,17 @@ class AirPodsNotifications {
fun isBatteryData(data: ByteArray): Boolean {
if (data.size != 22) {
Log.d("BatteryNotification", "Battery data size is not 22")
return false
}
return data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() &&
data[3] == 0x00.toByte() && data[4] == 0x04.toByte() && data[5] == 0x00.toByte()
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return
}
first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
Battery(first.component, first.level, data[10].toInt())
} else {
@@ -186,6 +194,7 @@ class AirPodsNotifications {
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0

View File

@@ -1,74 +0,0 @@
import bluetooth
import time
import threading
# Bluetooth MAC address of the target device
TARGET_MAC = "22:22:F5:BB:1C:A0" # Replace with the actual MAC address
UUID = "1abbb9a4-10e4-4000-a75c-8953c5471342"
# Define packets
PACKETS = {
"AIRPODS_CONNECTED": b"\x00\x01\x00\x01",
"AIRPODS_DISCONNECTED": b"\x00\x01\x00\x00",
"REQUEST_BATTERY_BYTES": b"\x00\x02\x00\x01",
"REQUEST_ANC_BYTES": b"\x00\x02\x00\x02",
"REQUEST_DISCONNECT": b"\x00\x02\x00\x00"
}
def send_packet(sock, packet_name):
if packet_name in PACKETS:
packet = PACKETS[packet_name]
sock.send(packet)
print(f"Sent packet: {packet_name}")
else:
print(f"Packet {packet_name} not defined.")
def listen_for_packets(sock):
try:
while True:
data = sock.recv(1024)
if data:
print(f"Received packet: {data}")
except Exception as e:
print(f"Error receiving data: {e}")
def main():
# Discover services to find the channel using the UUID
services = bluetooth.find_service(address=TARGET_MAC, uuid=UUID)
if len(services) == 0:
print(f"Could not find services for UUID {UUID}")
return
# Use the first service found
service = services[0]
port = service["port"]
name = service["name"]
host = service["host"]
print(f"Connecting to \"{name}\" on {host}, port {port}")
# Create a Bluetooth socket
sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM)
sock.connect((host, port))
print(f"Connected to {TARGET_MAC} on port {port}")
# Start listening for packets in a background thread
listener_thread = threading.Thread(target=listen_for_packets, args=(sock,))
listener_thread.daemon = True
listener_thread.start()
try:
while True:
packet_name = input("Enter packet name to send (or 'exit' to quit): ")
if packet_name.lower() == "exit":
break
send_packet(sock, packet_name)
time.sleep(1)
except Exception as e:
print(f"Error: {e}")
finally:
sock.close()
print("Connection closed.")
if __name__ == "__main__":
main()

View File

@@ -37,6 +37,8 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
class AirPodsTrayApp : public QObject {
Q_OBJECT
@@ -104,6 +106,7 @@ public:
}
}
initializeMprisInterface();
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
}
public slots:
@@ -340,6 +343,10 @@ public slots:
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) {
@@ -363,6 +370,7 @@ public slots:
QByteArray data = localSocket->readAll();
LOG_DEBUG("Data received: " << data.toHex());
parseData(data);
relayPacketToPhone(data);
});
});
@@ -403,6 +411,7 @@ public slots:
.arg(caseLevel);
LOG_INFO("Battery status: " << batteryStatus);
emit batteryStatusChanged(batteryStatus);
relayPacketToPhone(data);
} else if (data.size() == 10 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) {
LOG_INFO("Received conversational awareness data");
@@ -471,6 +480,67 @@ public slots:
} else {
LOG_WARN("No active MPRIS media players found");
}
connectToPhone();
}
void connectToPhone() {
if (phoneSocket && phoneSocket->isOpen()) {
LOG_INFO("Already connected to the phone");
return;
}
QBluetoothAddress phoneAddress(PHONE_MAC_ADDRESS);
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
LOG_INFO("Connected to phone");
});
connect(phoneSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this](QBluetoothSocket::SocketError error) {
LOG_ERROR("Phone socket error: " << error << ", " << phoneSocket->errorString());
});
phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342"));
}
void relayPacketToPhone(const QByteArray &packet) {
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 {
LOG_WARN("Phone socket is not open, cannot relay packet");
}
}
void handlePhonePacket(const QByteArray &packet) {
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
QByteArray airpodsPacket = packet.mid(4); // Remove the header
if (socket && socket->isOpen()) {
socket->write(airpodsPacket);
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
} else {
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
}
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
LOG_INFO("AirPods connected");
// Handle AirPods connected
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
LOG_INFO("AirPods disconnected");
// Handle AirPods disconnected
} else {
if (socket && socket->isOpen()) {
socket->write(packet);
LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex());
} else {
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
}
}
}
void onPhoneDataReceived() {
QByteArray data = phoneSocket->readAll();
LOG_DEBUG("Data received from phone: " << data.toHex());
handlePhonePacket(data);
}
signals:
@@ -483,6 +553,7 @@ private:
QMenu *trayMenu;
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
QBluetoothSocket *socket = nullptr;
QBluetoothSocket *phoneSocket = nullptr;
QDBusInterface *mprisInterface;
QString connectedDeviceMacAddress;
};