Skip to content

Commit

Permalink
android: disable audio profiles when not in ear; add a debug screen
Browse files Browse the repository at this point in the history
  • Loading branch information
kavishdevar committed Oct 15, 2024
1 parent f0c8a49 commit 4fd2717
Show file tree
Hide file tree
Showing 6 changed files with 377 additions and 53 deletions.
20 changes: 19 additions & 1 deletion android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />

<application
android:allowBackup="true"
Expand Down Expand Up @@ -36,6 +42,18 @@
android:exported="true"
android:foregroundServiceType="connectedDevice"
android:permission="android.permission.BLUETOOTH_CONNECT" />
</application>

<!-- <receiver android:name=".StartupReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.bluetooth.device.action.ACL_CONNECTED" />-->
<!-- <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.BOND_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.NAME_CHANGED" />-->
<!-- <action android:name="android.intent.action.BOOT_COMPLETED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.STATE_CHANGED" />-->
<!-- </intent-filter>-->
<!-- </receiver>-->
</application>
</manifest>
247 changes: 239 additions & 8 deletions android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothSocket
import android.content.BroadcastReceiver
import android.content.Context
Expand All @@ -23,6 +26,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass

private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV"
private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1
private const val APPLE = 0x004C
const val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
const val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL"
private const val PACKAGE_ASI = "com.google.android.settings.intelligence"
private const val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
//private const val COMPANION_TYPE_NONE = "COMPANION_NONE"
//const val VENDOR_RESULT_CODE_COMMAND_ANDROID = "+ANDROID"

class AirPodsService : Service() {
inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService
Expand Down Expand Up @@ -93,7 +106,7 @@ class AirPodsService : Service() {
fun getANC(): Int {
return ancNotification.status
}

//
// private fun buildBatteryText(battery: List<Battery>): String {
// val left = battery[0]
// val right = battery[1]
Expand All @@ -118,6 +131,165 @@ class AirPodsService : Service() {
return notificationBuilder.build()
}

fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter

bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}

override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)

bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}

override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}

fun connectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter

bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}

override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)

bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}

override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}

fun updatePodsStatus(device: BluetoothDevice, batteryList: List<Battery>) {
var batteryUnified = 0
var batteryUnifiedArg = 0

// Handle each Battery object from batteryList
// batteryList.forEach { battery ->
// when (battery.getComponentName()) {
// "LEFT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 10, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 13, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "RIGHT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 11, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 14, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "CASE" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 12, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 15, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// }
// }


// Sending broadcast for battery update
broadcastVendorSpecificEventIntent(
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV,
APPLE,
BluetoothHeadset.AT_CMD_TYPE_SET,
batteryUnified,
batteryUnifiedArg,
device
)
}

@Suppress("SameParameterValue")
@SuppressLint("MissingPermission")
private fun broadcastVendorSpecificEventIntent(
command: String,
companyId: Int,
commandType: Int,
batteryUnified: Int,
batteryUnifiedArg: Int,
device: BluetoothDevice
) {
val arguments = arrayOf(
1, // Number of key(IndicatorType)/value pairs
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level
batteryUnifiedArg // Battery Level
)

val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply {
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, command)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, commandType)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments)
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(BluetoothDevice.EXTRA_NAME, device.name)
addCategory(BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY + "." + companyId.toString())
}
sendBroadcast(intent)

val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply {
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(EXTRA_BATTERY_LEVEL, batteryUnified)
}
sendBroadcast(batteryIntent)

val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).setPackage(PACKAGE_ASI).apply {
putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent)
}
sendBroadcast(statusIntent)
}


fun setName(name: String) {
val nameBytes = name.toByteArray()
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01,
nameBytes.size.toByte(), 0x00) + nameBytes
socket?.outputStream?.write(bytes)
socket?.outputStream?.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPodsService", "setName: $name, sent packet: $hex")
}

@SuppressLint("MissingPermission", "InlinedApi")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {

Expand All @@ -133,6 +305,7 @@ class AirPodsService : Service() {

HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")

socket = HiddenApiBypass.newInstance(BluetoothSocket::class.java, 3, true, true, device, 0x1001, uuid) as BluetoothSocket?
try {
socket?.connect()
Expand All @@ -150,15 +323,25 @@ class AirPodsService : Service() {
MediaController.initialize(audioManager)
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
val data = buffer.copyOfRange(0, bytesRead)
var data: ByteArray = byteArrayOf()
if (bytesRead > 0) {
data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead))
})
val bytes = buffer.copyOfRange(0, bytesRead)
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPods Data", "Data received: $formattedHex")
}
else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
var inEar = false
var inEarData = listOf<Boolean>()
if (earDetectionNotification.isEarDetectionData(data)) {
earDetectionNotification.setStatus(data)
sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
Expand All @@ -169,7 +352,7 @@ class AirPodsService : Service() {
putExtra("data", bytes)
})
Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
var inEar = false
var justEnabledA2dp = false
val earReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data")
Expand All @@ -179,10 +362,52 @@ class AirPodsService : Service() {
} else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
}
if (inEar) {
MediaController.sendPlay()

val newInEarData = listOf(data[0] == 0x00.toByte(), data[1] == 0x00.toByte())
if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
connectAudio(this@AirPodsService, device)
justEnabledA2dp = true
val bluetoothAdapter = this@AirPodsService.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(
this@AirPodsService, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(
profile: Int,
proxy: BluetoothProfile
) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices =
proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPlay()
}
}
bluetoothAdapter.closeProfileProxy(
profile,
proxy
)
}

override fun onServiceDisconnected(
profile: Int
) {
}
}
,BluetoothProfile.A2DP
)

}
else if (newInEarData == listOf(false, false)){
disconnectAudio(this@AirPodsService, device)
}
else {

inEarData = newInEarData

if (inEar == true) {
if (!justEnabledA2dp) {
justEnabledA2dp = false
MediaController.sendPlay()
}
} else {
MediaController.sendPause()
}
}
Expand All @@ -209,25 +434,31 @@ class AirPodsService : Service() {
for (battery in batteryNotification.getBattery()) {
Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
}
// updatePodsStatus(device!!, batteryNotification.getBattery())
}
else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) {
conversationAwarenessNotification.setData(data)
sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
putExtra("data", conversationAwarenessNotification.status)
})


if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
MediaController.startSpeaking()
}
else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
} else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
MediaController.stopSpeaking()
}

Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
}
else { }
}
}
Log.d("AirPods Service", "Socket closed")
isRunning = false
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
}
}
}
Expand Down
Loading

0 comments on commit 4fd2717

Please sign in to comment.