diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 0f052f7..6975964 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
android {
namespace = "me.kavishdevar.aln"
- compileSdk = 34
+ compileSdk = 35
defaultConfig {
applicationId = "me.kavishdevar.aln"
@@ -53,6 +53,7 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.annotations)
implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -60,4 +61,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
+ implementation("com.github.prime-zs.toolkit:core-ktx:2.1.0")
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e01ed6f..8494567 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,8 @@
+
+
@@ -49,6 +51,12 @@
+
+ if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
+ bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+ if (profile == BluetoothProfile.A2DP) {
+ val connectedDevices = proxy.connectedDevices
+ if (connectedDevices.isNotEmpty()) {
+ connectToSocket(device)
+ }
+ }
+ bluetoothAdapter.closeProfileProxy(profile, proxy)
+ }
+
+ override fun onServiceDisconnected(profile: Int) { }
+ }, BluetoothProfile.A2DP)
+ }
+ }
+
+ return START_STICKY
+ }
+
+ private lateinit var socket: BluetoothSocket
+
+ @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
+ fun connectToSocket(device: BluetoothDevice) {
+ HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
+ val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
+
+ try {
+ socket = HiddenApiBypass.newInstance(
+ BluetoothSocket::class.java,
+ 3,
+ true,
+ true,
+ device,
+ 0x1001,
+ uuid
+ ) as BluetoothSocket
+ }
+ catch (
+ e: Exception
+ ) {
+ e.printStackTrace()
+ try {
+ socket = HiddenApiBypass.newInstance(
+ BluetoothSocket::class.java,
+ 3,
+ 1,
+ true,
+ true,
+ device,
+ 0x1001,
+ uuid
+ ) as BluetoothSocket
+ }
+ catch (
+ e: Exception
+ ) {
+ e.printStackTrace()
+ }
+ }
+
+ try {
+ socket.connect()
+ this@AirPodsService.device = device
+ isConnected = true
+ socket.let { it ->
+ it.outputStream.write(Enums.HANDSHAKE.value)
+ it.outputStream.flush()
+ it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
+ it.outputStream.flush()
+ it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
+ it.outputStream.flush()
+ sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_CONNECTED)
+ .putExtra("device", device)
+ )
+ CoroutineScope(Dispatchers.IO).launch {
+ while (socket.isConnected == true) {
+ socket.let {
+ val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
+ MediaController.initialize(audioManager)
+ val buffer = ByteArray(1024)
+ val bytesRead = it.inputStream.read(buffer)
+ 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()
+ if (earDetectionNotification.isEarDetectionData(data)) {
+ earDetectionNotification.setStatus(data)
+ sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
+ val list = earDetectionNotification.status
+ val bytes = ByteArray(2)
+ bytes[0] = list[0]
+ bytes[1] = list[1]
+ putExtra("data", bytes)
+ })
+ Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
+ var justEnabledA2dp = false
+ val earReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val data = intent.getByteArrayExtra("data")
+ if (data != null && earDetectionEnabled) {
+ inEar = if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
+ data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
+ } else {
+ data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
+ }
+
+ 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)
+ }
+
+ inEarData = newInEarData
+
+ if (inEar == true) {
+ if (!justEnabledA2dp) {
+ justEnabledA2dp = false
+ MediaController.sendPlay()
+ }
+ } else {
+ MediaController.sendPause()
+ }
+ }
+ }
+ }
+
+ val earIntentFilter = IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ this@AirPodsService.registerReceiver(earReceiver, earIntentFilter,
+ RECEIVER_EXPORTED
+ )
+ } else {
+ this@AirPodsService.registerReceiver(earReceiver, earIntentFilter)
+ }
+ }
+ else if (ancNotification.isANCData(data)) {
+ ancNotification.setStatus(data)
+ sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
+ putExtra("data", ancNotification.status)
+ })
+ Log.d("AirPods Parser", "ANC: ${ancNotification.status}")
+ }
+ else if (batteryNotification.isBatteryData(data)) {
+ batteryNotification.setBattery(data)
+ sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
+ putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
+ })
+ for (battery in batteryNotification.getBattery()) {
+ Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
+ }
+// if both are charging, disconnect audio profiles
+ if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) {
+ disconnectAudio(this@AirPodsService, device)
+ }
+ else {
+ connectAudio(this@AirPodsService, device)
+ }
+// 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()) {
+ MediaController.stopSpeaking()
+ }
+
+ Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
+ }
+ else { }
+ }
+ }
+ Log.d("AirPods Service", "Socket closed")
+ isConnected = false
+ this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
+ socket.close()
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
+ }
+ }
+ }
+ catch (e: Exception) {
+ e.printStackTrace()
+ Log.d("AirPodsService", "Failed to connect to socket")
+ }
+ }
- var isConnected: Boolean = false
- private var socket: BluetoothSocket? = null
fun sendPacket(packet: String) {
val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
- socket?.outputStream?.write(fromHex.toByteArray())
- socket?.outputStream?.flush()
+ socket.outputStream?.write(fromHex.toByteArray())
+ socket.outputStream?.flush()
}
fun setANCMode(mode: Int) {
when (mode) {
1 -> {
- socket?.outputStream?.write(Enums.NOISE_CANCELLATION_OFF.value)
+ socket.outputStream?.write(Enums.NOISE_CANCELLATION_OFF.value)
}
2 -> {
- socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ON.value)
+ socket.outputStream?.write(Enums.NOISE_CANCELLATION_ON.value)
}
3 -> {
- socket?.outputStream?.write(Enums.NOISE_CANCELLATION_TRANSPARENCY.value)
+ socket.outputStream?.write(Enums.NOISE_CANCELLATION_TRANSPARENCY.value)
}
4 -> {
- socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
+ socket.outputStream?.write(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
}
}
- socket?.outputStream?.flush()
+ socket.outputStream?.flush()
}
fun setCAEnabled(enabled: Boolean) {
- socket?.outputStream?.write(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
+ socket.outputStream?.write(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
+ socket.outputStream?.flush()
}
fun setOffListeningMode(enabled: Boolean) {
- socket?.outputStream?.write(byteArrayOf(0x04, 0x00 ,0x04, 0x00, 0x09, 0x00, 0x34, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00))
+ socket.outputStream?.write(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)
- socket?.outputStream?.write(bytes)
- socket?.outputStream?.flush()
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
+ }
+
+ fun setPressSpeed(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x17, speed.toByte(), 0x00, 0x00, 0x00)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
+ }
+
+ fun setPressAndHoldDuration(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, speed.toByte(), 0x00, 0x00, 0x00)
+ socket.outputStream?.write(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)
+ socket.outputStream?.write(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)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
+ }
+
+ fun setVolumeSwipeSpeed(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x23, speed.toByte(), 0x00, 0x00, 0x00)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
+ }
+
+ fun setToneVolume(volume: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
@@ -111,8 +447,8 @@ class AirPodsService : Service() {
fun setCaseChargingSounds(enabled: Boolean) {
val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01)
- socket?.outputStream?.write(bytes)
- socket?.outputStream?.flush()
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
}
fun setEarDetection(enabled: Boolean) {
@@ -126,14 +462,6 @@ class AirPodsService : Service() {
fun getANC(): Int {
return ancNotification.status
}
-//
-// private fun buildBatteryText(battery: List): String {
-// val left = battery[0]
-// val right = battery[1]
-// val case = battery[2]
-//
-// return "Left: ${left.level}% ${left.getStatusName()}, Right: ${right.level}% ${right.getStatusName()}, Case: ${case.level}% ${case.getStatusName()}"
-// }
private fun createNotification(): Notification {
val channelId = "battery"
@@ -227,288 +555,31 @@ class AirPodsService : Service() {
}, BluetoothProfile.HEADSET)
}
- fun updatePodsStatus(device: BluetoothDevice, batteryList: List) {
- 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()
+ 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 {
-
- val notification = createNotification()
- startForeground(1, notification)
-
- ServiceManager.setService(this)
-
- if (isConnected) {
- return START_STICKY
- }
- isConnected = true
-
- @Suppress("DEPRECATION") val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) intent?.getParcelableExtra("device", BluetoothDevice::class.java) else intent?.getParcelableExtra("device")
-
- 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()
- socket?.let { it ->
- it.outputStream.write(Enums.HANDSHAKE.value)
- it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
- it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED))
- it.outputStream.flush()
-
- CoroutineScope(Dispatchers.IO).launch {
- while (socket?.isConnected == true) {
- socket?.let {
- val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
- MediaController.initialize(audioManager)
- val buffer = ByteArray(1024)
- val bytesRead = it.inputStream.read(buffer)
- 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()
- if (earDetectionNotification.isEarDetectionData(data)) {
- earDetectionNotification.setStatus(data)
- sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
- val list = earDetectionNotification.status
- val bytes = ByteArray(2)
- bytes[0] = list[0]
- bytes[1] = list[1]
- putExtra("data", bytes)
- })
- Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
- var justEnabledA2dp = false
- val earReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- val data = intent.getByteArrayExtra("data")
- if (data != null && earDetectionEnabled) {
- inEar = if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
- data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
- } else {
- data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
- }
-
- 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)
- }
-
- inEarData = newInEarData
-
- if (inEar == true) {
- if (!justEnabledA2dp) {
- justEnabledA2dp = false
- MediaController.sendPlay()
- }
- } else {
- MediaController.sendPause()
- }
- }
- }
- }
-
- val earIntentFilter = IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
- this@AirPodsService.registerReceiver(earReceiver, earIntentFilter,
- RECEIVER_EXPORTED
- )
- }
- else if (ancNotification.isANCData(data)) {
- ancNotification.setStatus(data)
- sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
- putExtra("data", ancNotification.status)
- })
- Log.d("AirPods Parser", "ANC: ${ancNotification.status}")
- }
- else if (batteryNotification.isBatteryData(data)) {
- batteryNotification.setBattery(data)
- sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
- putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
- })
- 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()) {
- MediaController.stopSpeaking()
- }
-
- Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
- }
- else { }
- }
- }
- Log.d("AirPods Service", "Socket closed")
- isConnected = false
- this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
- socket?.close()
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
- }
- }
- }
- catch (e: Exception) {
- Log.e("AirPodsSettingsScreen", "Error connecting to device: ${e.message}")
- }
- return START_STICKY
- }
-
- override fun onDestroy() {
- super.onDestroy()
- socket?.close()
- isConnected = false
- ServiceManager.setService(null)
- }
-
fun setPVEnabled(enabled: Boolean) {
var hex = "04 00 04 00 09 00 26 ${if (enabled) "01" else "02"} 00 00 00"
var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- socket?.outputStream?.write(bytes)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
hex = "04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00"
bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- socket?.outputStream?.write(bytes)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
}
fun setLoudSoundReduction(enabled: Boolean) {
val hex = "52 1B 00 0${if (enabled) "1" else "0"}"
val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- socket?.outputStream?.write(bytes)
+ socket.outputStream?.write(bytes)
+ socket.outputStream?.flush()
}
}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt
index 35e2de5..bb4c3a1 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt
@@ -37,14 +37,18 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -77,6 +81,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
+import com.primex.core.ExperimentalToolkitApi
+import com.primex.core.blur.newBackgroundBlur
import kotlin.math.roundToInt
@Composable
@@ -150,6 +156,251 @@ fun BatteryView() {
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
+ val sliderValue = remember { mutableFloatStateOf(0f) }
+ LaunchedEffect(sliderValue) {
+ if (sharedPreferences.contains("tone_volume")) {
+ sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
+ }
+ }
+ LaunchedEffect(sliderValue.floatValue) {
+ sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
+ }
+
+ val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
+
+ val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
+ val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
+ val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
+ val labelTextColor = if (isDarkTheme) Color.White else Color.Black
+
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "\uDBC0\uDEA1",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ fontWeight = FontWeight.Light,
+ color = labelTextColor
+ ),
+ modifier = Modifier.padding(start = 4.dp)
+ )
+ Slider(
+ value = sliderValue.floatValue,
+ onValueChange = {
+ sliderValue.floatValue = it
+ service.setToneVolume(volume = it.toInt())
+ },
+ valueRange = 0f..100f,
+ onValueChangeFinished = {
+ // Round the value when the user stops sliding
+ sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(36.dp), // Adjust height to ensure thumb fits well
+ colors = SliderDefaults.colors(
+ thumbColor = thumbColor,
+ activeTrackColor = activeTrackColor,
+ inactiveTrackColor = trackColor
+ ),
+ thumb = {
+ Box(
+ modifier = Modifier
+ .size(24.dp) // Circular thumb size
+ .shadow(4.dp, CircleShape) // Apply shadow to the thumb
+ .background(thumbColor, CircleShape) // Circular thumb
+ )
+ },
+ track = {
+ Box (
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(12.dp),
+ contentAlignment = Alignment.CenterStart
+ )
+ {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(trackColor, RoundedCornerShape(4.dp))
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(sliderValue.value / 100)
+ .height(4.dp)
+ .background(activeTrackColor, RoundedCornerShape(4.dp))
+ )
+ }
+ }
+ )
+ Text(
+ text = "\uDBC0\uDEA9",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ fontWeight = FontWeight.Light,
+ color = labelTextColor
+ ),
+ modifier = Modifier.padding(end = 4.dp)
+ )
+ }
+}
+
+@Composable
+fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
+ var singleANCEnabled by remember {
+ mutableStateOf(
+ sharedPreferences.getBoolean("single_anc", true)
+ )
+ }
+
+ // Update the service when the toggle is changed
+ fun updateSingleEnabled(enabled: Boolean) {
+ singleANCEnabled = enabled
+ sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
+ service.setNoiseCancellationWithOnePod(enabled)
+ }
+
+ val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val isPressed = remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) { // Detect press state for iOS-like effect
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease() // Wait until release
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null, // Disable ripple effect
+ interactionSource = remember { MutableInteractionSource() } // Required for clickable
+ ) {
+ // Toggle the conversational awareness value
+ updateSingleEnabled(!singleANCEnabled)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = "Noise Cancellation with Single AirPod",
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description
+ Text(
+ text = "Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.",
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ )
+ }
+ StyledSwitch(
+ checked = singleANCEnabled,
+ onCheckedChange = {
+ updateSingleEnabled(it)
+ },
+ )
+ }
+}
+
+@Composable
+fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
+ var volumeControlEnabled by remember {
+ mutableStateOf(
+ sharedPreferences.getBoolean("volume_control", true)
+ )
+ }
+
+ // Update the service when the toggle is changed
+ fun updateVolumeControlEnabled(enabled: Boolean) {
+ volumeControlEnabled = enabled
+ sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
+ service.setVolumeControl(enabled)
+ }
+
+ val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val isPressed = remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) { // Detect press state for iOS-like effect
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease() // Wait until release
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null, // Disable ripple effect
+ interactionSource = remember { MutableInteractionSource() } // Required for clickable
+ ) {
+ // Toggle the conversational awareness value
+ updateVolumeControlEnabled(!volumeControlEnabled)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = "Volume Control",
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Spacer(modifier = Modifier.height(4.dp)) // Small space between main text and description
+ Text(
+ text = "Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.",
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ )
+ }
+ StyledSwitch(
+ checked = volumeControlEnabled,
+ onCheckedChange = {
+ updateVolumeControlEnabled(it)
+ },
+ )
+ }
+}
+
@Composable
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
@@ -173,75 +424,151 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp)
) {
- //
+
+// Tone Volume Slider
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ ) {
+ Text(
+ text = "Tone Volume",
+ modifier = Modifier
+ .padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
+ .fillMaxWidth(),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor
+ )
+ )
+
+ ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
+ }
+
+// Dropdown menu with 3 options, Default, Slower, Slowest – Press speed
+// Dropdown menu with 3 options, Default, Slower, Slowest – Press and hold duration
+// IndependentToggle for Noise Cancellation with one AirPod
+// IndependentToggle for Enable Volume Control
+// Dropdown menu with 3 options, Default, Slower, Slowest – Volume Swipe Speed
+
+// IndependentToggle(name = "Noise Cancellation with one AirPod", service = service, functionName = "setNoiseCancellationWithOnePod", sharedPreferences = sharedPreferences, false)
+
+ SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
+
+// IndependentToggle(name = "Enable Volume Control", service = service, functionName = "setVolumeControl", sharedPreferences = sharedPreferences, true)
+
+ VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
}
}
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalToolkitApi::class)
@SuppressLint("MissingPermission", "NewApi")
@Composable
-fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?,
+fun AirPodsSettingsScreen(device: BluetoothDevice?, service: AirPodsService?,
navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var deviceName by remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", device?.name ?: "") ?: "")) }
// 4B 61 76 69 73 68 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F
val verticalScrollState = rememberScrollState()
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(vertical = 24.dp, horizontal = 12.dp)
- .verticalScroll(
- state = verticalScrollState,
- enabled = true,
+ @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
+ Scaffold(
+ containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
+ 0xFF000000
+ ) else Color(
+ 0xFFF2F2F7
+ ),
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = device!!.name,
+ color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black,
+ )
+ },
+ modifier = Modifier
+ .newBackgroundBlur(
+ radius = 24.dp, // the radius of the blur effect, in pixels)
+ ),
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.Black.copy(0.2f) else Color(0xFFF2F2F7).copy(0.2f),
+ )
)
- ) {
- LaunchedEffect(service) {
- service?.let {
- it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
- putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
- })
- it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
- putExtra("data", it.getANC())
- })
- }
+ HorizontalDivider(thickness = 3.dp, color = Color.DarkGray)
}
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
-
- if (service != null) {
- BatteryView()
-
- Spacer(modifier = Modifier.height(32.dp))
- StyledTextField(
- name = "Name",
- value = deviceName.text,
- onValueChange = {
- deviceName = TextFieldValue(it)
- sharedPreferences.edit().putString("name", it).apply()
- service.setName(it)
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 8.dp)
+// .padding(top = 55.dp, bottom = 32.dp)
+ .verticalScroll(
+ state = verticalScrollState,
+ enabled = true,
+ )
+ ) {
+ Spacer(Modifier.height(75.dp))
+ LaunchedEffect(service) {
+ service?.let {
+ it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
+ putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
+ })
+ it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
+ putExtra("data", it.getANC())
+ })
}
- )
+ }
+ val sharedPreferences =
+ LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
- Spacer(modifier = Modifier.height(32.dp))
- NoiseControlSettings(service = service)
+ if (service != null) {
+ BatteryView()
- Spacer(modifier = Modifier.height(16.dp))
- AudioSettings(service = service, sharedPreferences = sharedPreferences)
+ Spacer(modifier = Modifier.height(32.dp))
+ StyledTextField(
+ name = "Name",
+ value = deviceName.text,
+ onValueChange = {
+ deviceName = TextFieldValue(it)
+ sharedPreferences.edit().putString("name", it).apply()
+ service.setName(it)
+ }
+ )
- Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(name = "Automatic Ear Detection", service = service, functionName = "setEarDetection", sharedPreferences = sharedPreferences, true)
+ Spacer(modifier = Modifier.height(32.dp))
+ NoiseControlSettings(service = service)
- Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(name = "Off Listening Mode", service = service, functionName = "setOffListeningMode", sharedPreferences = sharedPreferences, false)
+ Spacer(modifier = Modifier.height(16.dp))
+ AudioSettings(service = service, sharedPreferences = sharedPreferences)
- Spacer(modifier = Modifier.height(16.dp))
- AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
+ Spacer(modifier = Modifier.height(16.dp))
+ IndependentToggle(
+ name = "Automatic Ear Detection",
+ service = service,
+ functionName = "setEarDetection",
+ sharedPreferences = sharedPreferences,
+ true
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ IndependentToggle(
+ name = "Off Listening Mode",
+ service = service,
+ functionName = "setOffListeningMode",
+ sharedPreferences = sharedPreferences,
+ false
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
// Spacer(modifier = Modifier.height(16.dp))
// val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
// val textColor = if (isDarkTheme) Color.White else Color.Black
- // localstorage stuff
- // TODO: localstorage and call the setButtons() with previous configuration and new configuration
+ // localstorage stuff
+ // TODO: localstorage and call the setButtons() with previous configuration and new configuration
// Box (
// modifier = Modifier
// .padding(vertical = 8.dp)
@@ -254,28 +581,44 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?
// // TODO: A Column Rows with text at start and a check mark if ticked
// }
- Spacer(modifier = Modifier.height(16.dp))
- Row (
- modifier = Modifier
- .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
- .height(55.dp)
- .clickable {
- navController.navigate("debug")
- }
- ) {
- Text(text = "Debug", modifier = Modifier.padding(16.dp), color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black)
- Spacer(modifier = Modifier.weight(1f))
- IconButton(
- onClick = { navController.navigate("debug") },
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = Color.Transparent,
- contentColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black ),
- modifier = Modifier.padding(start = 16.dp).fillMaxHeight()
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier
+ .background(
+ if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
+ 0xFF1C1C1E
+ ) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)
+ )
+ .height(55.dp)
+ .clickable {
+ navController.navigate("debug")
+ }
) {
- @Suppress("DEPRECATION")
- Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = "Debug")
+ Text(
+ text = "Debug",
+ modifier = Modifier.padding(16.dp),
+ color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ IconButton(
+ onClick = { navController.navigate("debug") },
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = Color.Transparent,
+ contentColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black
+ ),
+ modifier = Modifier
+ .padding(start = 16.dp)
+ .fillMaxHeight()
+ ) {
+ @Suppress("DEPRECATION")
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowRight,
+ contentDescription = "Debug"
+ )
+ }
}
}
+ Spacer(Modifier.height(24.dp))
}
}
}
@@ -296,7 +639,6 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
- val activeTrackColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFF007AFF)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
@@ -323,7 +665,6 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
.height(36.dp), // Adjust height to ensure thumb fits well
colors = SliderDefaults.colors(
thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
@@ -338,9 +679,18 @@ fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPrefere
Box(
modifier = Modifier
.fillMaxWidth()
- .height(12.dp)
- .background(trackColor, RoundedCornerShape(6.dp))
+ .height(12.dp),
+ contentAlignment = Alignment.CenterStart
)
+ {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(trackColor, RoundedCornerShape(4.dp))
+ )
+ }
+
}
)
@@ -695,15 +1045,15 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
color = textColor
)
)
Text(
text = "Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.",
modifier = Modifier
- .padding(8.dp, top = 2.dp)
+ .padding(bottom = 8.dp, top = 2.dp)
+ .padding(end = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 12.sp,
diff --git a/android/app/src/main/java/me/kavishdevar/aln/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/aln/BootReceiver.kt
new file mode 100644
index 0000000..38c4f34
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/aln/BootReceiver.kt
@@ -0,0 +1,4 @@
+package me.kavishdevar.aln
+
+class BootReceiver {
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt
index db84c3e..0bd5cf5 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt
@@ -1,9 +1,6 @@
package me.kavishdevar.aln
import android.annotation.SuppressLint
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothManager
-import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
@@ -13,7 +10,6 @@ import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle
import android.os.IBinder
-import android.os.ParcelUuid
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -22,28 +18,16 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
-import androidx.core.content.ContextCompat.getSystemService
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -51,98 +35,35 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
+import com.primex.core.ExperimentalToolkitApi
import me.kavishdevar.aln.ui.theme.ALNTheme
+
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
+ @OptIn(ExperimentalToolkitApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
- val topAppBarTitle = remember { mutableStateOf("AirPods Pro") }
ALNTheme {
- val navController = rememberNavController()
- registerReceiver(object: BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent) {
- val bluetoothDevice =
- intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java)
- val action = intent.action
-
- // Airpods filter
- if (bluetoothDevice != null && action != null && !action.isEmpty()) {
- Log.d("BluetoothReceiver", "Received broadcast")
- // Airpods connected, show notification.
- if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
- val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
- if (bluetoothDevice.uuids.contains(uuid)) {
- topAppBarTitle.value = bluetoothDevice.name
- }
- // start service
- startService(Intent(context, AirPodsService::class.java).apply {
- putExtra("device", bluetoothDevice)
- })
- Log.d("AirPodsService", "Service started")
- context?.sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED))
- }
-
- // Airpods disconnected, remove notification but leave the scanner going.
- if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action
- || BluetoothDevice.ACTION_ACL_DISCONNECT_REQUESTED == action
- ) {
- topAppBarTitle.value = "AirPods Pro"
- // stop service
- stopService(Intent(context, AirPodsService::class.java))
- Log.d("AirPodsService", "Service stopped")
- }
- }
- }
- }, BluetoothReceiver.buildFilter())
-
- Scaffold (
- containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(
- text = topAppBarTitle.value,
- color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black,
- )
- },
- colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
- containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- )
- )
- }
- ) { innerPadding ->
- Main(innerPadding, topAppBarTitle)
- }
+ Main()
+ startService(Intent(this, AirPodsService::class.java))
}
}
}
}
-@SuppressLint("MissingPermission")
+@SuppressLint("MissingPermission", "InlinedApi")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
-fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) {
+fun Main() {
val bluetoothConnectPermissionState = rememberPermissionState(
permission = "android.permission.BLUETOOTH_CONNECT"
)
if (bluetoothConnectPermissionState.status.isGranted) {
val context = LocalContext.current
- val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
- val bluetoothManager = getSystemService(context, BluetoothManager::class.java)
- val bluetoothAdapter = bluetoothManager?.adapter
- val airpodsDevice = remember { mutableStateOf(null) }
val airPodsService = remember { mutableStateOf(null) }
val navController = rememberNavController()
@@ -157,58 +78,6 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) {
Context.RECEIVER_NOT_EXPORTED)
}
- // Service connection for AirPodsService
- val serviceConnection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName, service: IBinder) {
- val binder = service as AirPodsService.LocalBinder
- airPodsService.value = binder.getService()
- Log.d("AirPodsService", "Service connected")
- }
-
- override fun onServiceDisconnected(name: ComponentName) {
- airPodsService.value = null
- }
- }
-
- // Function to check if AirPods are connected
- fun checkIfAirPodsConnected() {
- val devices = bluetoothAdapter?.bondedDevices
- devices?.forEach { device ->
- if (device.uuids.contains(uuid)) {
- bluetoothAdapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
- override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
- if (profile == BluetoothProfile.A2DP) {
- val connectedDevices = proxy.connectedDevices
- if (connectedDevices.isNotEmpty()) {
- airpodsDevice.value = device
- val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
- topAppBarTitle.value = sharedPreferences.getString("name", device.name) ?: device.name
- // Start AirPods service if not running
- if (context.getSystemService(AirPodsService::class.java)?.isConnected != true) {
- context.startService(Intent(context, AirPodsService::class.java).apply {
- putExtra("device", device)
- })
- context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
- }
- } else {
- airpodsDevice.value = null
- }
- }
- bluetoothAdapter.closeProfileProxy(profile, proxy)
- }
-
- override fun onServiceDisconnected(profile: Int) {}
- }, BluetoothProfile.A2DP)
- }
- }
- }
-
- // Register the receiver in LaunchedEffect
- LaunchedEffect(Unit) {
- // Initial check for AirPods connection
- checkIfAirPodsConnected()
- }
-
// UI logic
NavHost(
navController = navController,
@@ -223,8 +92,7 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) {
}
composable("settings") {
AirPodsSettingsScreen(
- paddingValues,
- airpodsDevice.value,
+ device = airPodsService.value?.device,
service = airPodsService.value,
navController = navController
)
@@ -234,30 +102,37 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) {
}
}
- ContextCompat.registerReceiver(
- context,
- object : BroadcastReceiver() {
- @SuppressLint("UnspecifiedRegisterReceiverFlag")
- override fun onReceive(context: Context?, intent: Intent) {
- Log.d("PLEASE NAVIGATE", "TO SETTINGS")
- navController.navigate("settings") {
- popUpTo("notConnected") { inclusive = true }
- }
- }
- },
- IntentFilter(AirPodsNotifications.AIRPODS_CONNECTED),
- ContextCompat.RECEIVER_NOT_EXPORTED
- )
+ val receiver = object: BroadcastReceiver() {
+ override fun onReceive(p0: Context?, p1: Intent?) {
+ navController.navigate("settings")
+ navController.popBackStack("notConnected", inclusive = true)
+ }
+ }
- // Automatically navigate to settings screen if AirPods are connected
- if (airpodsDevice.value != null) {
- LaunchedEffect(Unit) {
- navController.navigate("settings") {
- popUpTo("notConnected") { inclusive = true }
+ context.registerReceiver(receiver, IntentFilter(AirPodsNotifications.AIRPODS_CONNECTED),
+ Context.RECEIVER_EXPORTED)
+
+ val serviceConnection = remember {
+ object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ val binder = service as AirPodsService.LocalBinder
+ airPodsService.value = binder.getService()
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ airPodsService.value = null
}
}
+ }
+
+ context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
+
+ if (airPodsService.value?.isConnected == true) {
+ Log.d("ALN", "Connected")
+ navController.navigate("settings")
} else {
- Text("No AirPods connected")
+ Log.d("ALN", "Not connected")
+ navController.navigate("notConnected")
}
} else {
// Permission is not granted, request it
@@ -277,10 +152,4 @@ fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) {
}
}
}
-}
-
-@PreviewLightDark
-@Composable
-fun PreviewAirPodsSettingsScreen() {
- AirPodsSettingsScreen(paddingValues = PaddingValues(0.dp), device = null, service = null, navController = rememberNavController())
}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/aln/OldAirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/OldAirPodsService.kt
new file mode 100644
index 0000000..deb8be0
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/aln/OldAirPodsService.kt
@@ -0,0 +1,463 @@
+@file:Suppress("unused")
+
+package me.kavishdevar.aln
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.bluetooth.BluetoothSocket
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioManager
+import android.os.Binder
+import android.os.Build
+import android.os.IBinder
+import android.os.ParcelUuid
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.lsposed.hiddenapibypass.HiddenApiBypass
+
+object ServiceManager {
+ private var service: OldAirPodsService? = null
+ @Synchronized
+ fun getService(): OldAirPodsService? {
+ return service
+ }
+ @Synchronized
+ fun setService(service: OldAirPodsService?) {
+ this.service = service
+ }
+}
+
+class OldAirPodsService : Service() {
+ inner class LocalBinder : Binder() {
+ fun getService(): OldAirPodsService = this@OldAirPodsService
+ }
+
+ override fun onBind(intent: Intent?): IBinder {
+ return LocalBinder()
+ }
+
+ var isConnected: Boolean = false
+ private var socket: BluetoothSocket? = null
+
+ fun sendPacket(packet: String) {
+ val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
+ socket?.outputStream?.write(fromHex.toByteArray())
+ socket?.outputStream?.flush()
+ }
+
+ fun setANCMode(mode: Int) {
+ when (mode) {
+ 1 -> {
+ socket?.outputStream?.write(Enums.NOISE_CANCELLATION_OFF.value)
+ }
+ 2 -> {
+ socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ON.value)
+ }
+ 3 -> {
+ socket?.outputStream?.write(Enums.NOISE_CANCELLATION_TRANSPARENCY.value)
+ }
+ 4 -> {
+ socket?.outputStream?.write(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
+ }
+ }
+ socket?.outputStream?.flush()
+ }
+
+ fun setCAEnabled(enabled: Boolean) {
+ socket?.outputStream?.write(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
+ }
+
+ fun setOffListeningMode(enabled: Boolean) {
+ socket?.outputStream?.write(byteArrayOf(0x04, 0x00 ,0x04, 0x00, 0x09, 0x00, 0x34, if (enabled) 0x01 else 0x02, 0x00, 0x00, 0x00))
+ }
+
+ fun setAdaptiveStrength(strength: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ fun setPressSpeed(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x17, speed.toByte(), 0x00, 0x00, 0x00)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ fun setPressAndHoldDuration(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, speed.toByte(), 0x00, 0x00, 0x00)
+ socket?.outputStream?.write(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)
+ socket?.outputStream?.write(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)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ fun setVolumeSwipeSpeed(speed: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x23, speed.toByte(), 0x00, 0x00, 0x00)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ fun setToneVolume(volume: Int) {
+ val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ val earDetectionNotification = AirPodsNotifications.EarDetection()
+ val ancNotification = AirPodsNotifications.ANC()
+ val batteryNotification = AirPodsNotifications.BatteryNotification()
+ val conversationAwarenessNotification = AirPodsNotifications.ConversationalAwarenessNotification()
+
+ var earDetectionEnabled = true
+
+ fun setCaseChargingSounds(enabled: Boolean) {
+ val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01)
+ socket?.outputStream?.write(bytes)
+ socket?.outputStream?.flush()
+ }
+
+ fun setEarDetection(enabled: Boolean) {
+ earDetectionEnabled = enabled
+ }
+
+ fun getBattery(): List {
+ return batteryNotification.getBattery()
+ }
+
+ fun getANC(): Int {
+ return ancNotification.status
+ }
+
+ private fun createNotification(): Notification {
+ val channelId = "battery"
+ val notificationBuilder = NotificationCompat.Builder(this, channelId)
+ .setSmallIcon(R.drawable.pro_2_buds)
+ .setContentTitle("AirPods Connected")
+ .setOngoing(true)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+
+ val channel =
+ NotificationChannel(channelId, "Battery Notification", NotificationManager.IMPORTANCE_LOW)
+
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ 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 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("OldAirPodsService", "setName: $name, sent packet: $hex")
+ }
+
+ @SuppressLint("MissingPermission", "InlinedApi")
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+
+ val notification = createNotification()
+ startForeground(1, notification)
+
+ ServiceManager.setService(this)
+
+ if (isConnected) {
+ return START_STICKY
+ }
+ isConnected = true
+
+ @Suppress("DEPRECATION") val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) intent?.getParcelableExtra("device", BluetoothDevice::class.java) else intent?.getParcelableExtra("device")
+
+ 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()
+ socket?.let { it ->
+ it.outputStream.write(Enums.HANDSHAKE.value)
+ it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
+ it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED))
+ it.outputStream.flush()
+
+ CoroutineScope(Dispatchers.IO).launch {
+ while (socket?.isConnected == true) {
+ socket?.let {
+ val audioManager = this@OldAirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
+ MediaController.initialize(audioManager)
+ val buffer = ByteArray(1024)
+ val bytesRead = it.inputStream.read(buffer)
+ 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@OldAirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
+ socket?.close()
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
+ return@launch
+ }
+ var inEar = false
+ var inEarData = listOf()
+ if (earDetectionNotification.isEarDetectionData(data)) {
+ earDetectionNotification.setStatus(data)
+ sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
+ val list = earDetectionNotification.status
+ val bytes = ByteArray(2)
+ bytes[0] = list[0]
+ bytes[1] = list[1]
+ putExtra("data", bytes)
+ })
+ Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
+ var justEnabledA2dp = false
+ val earReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val data = intent.getByteArrayExtra("data")
+ if (data != null && earDetectionEnabled) {
+ inEar = if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
+ data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
+ } else {
+ data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
+ }
+
+ val newInEarData = listOf(data[0] == 0x00.toByte(), data[1] == 0x00.toByte())
+ if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
+ connectAudio(this@OldAirPodsService, device)
+ justEnabledA2dp = true
+ val bluetoothAdapter = this@OldAirPodsService.getSystemService(BluetoothManager::class.java).adapter
+ bluetoothAdapter.getProfileProxy(
+ this@OldAirPodsService, 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@OldAirPodsService, device)
+ }
+
+ inEarData = newInEarData
+
+ if (inEar == true) {
+ if (!justEnabledA2dp) {
+ justEnabledA2dp = false
+ MediaController.sendPlay()
+ }
+ } else {
+ MediaController.sendPause()
+ }
+ }
+ }
+ }
+
+ val earIntentFilter = IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
+ this@OldAirPodsService.registerReceiver(earReceiver, earIntentFilter,
+ RECEIVER_EXPORTED
+ )
+ }
+ else if (ancNotification.isANCData(data)) {
+ ancNotification.setStatus(data)
+ sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
+ putExtra("data", ancNotification.status)
+ })
+ Log.d("AirPods Parser", "ANC: ${ancNotification.status}")
+ }
+ else if (batteryNotification.isBatteryData(data)) {
+ batteryNotification.setBattery(data)
+ sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
+ putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
+ })
+ for (battery in batteryNotification.getBattery()) {
+ Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
+ }
+// if both are charging, disconnect audio profiles
+ if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) {
+ disconnectAudio(this@OldAirPodsService, device)
+ }
+ else {
+ connectAudio(this@OldAirPodsService, device)
+ }
+// 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()) {
+ MediaController.stopSpeaking()
+ }
+
+ Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
+ }
+ else { }
+ }
+ }
+ Log.d("AirPods Service", "Socket closed")
+ isConnected = false
+ this@OldAirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
+ socket?.close()
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
+ }
+ }
+ }
+ catch (e: Exception) {
+ Log.e("AirPodsSettingsScreen", "Error connecting to device: ${e.message}")
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ socket?.close()
+ isConnected = false
+ ServiceManager.setService(null)
+ }
+
+ fun setPVEnabled(enabled: Boolean) {
+ var hex = "04 00 04 00 09 00 26 ${if (enabled) "01" else "02"} 00 00 00"
+ var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
+ socket?.outputStream?.write(bytes)
+ hex = "04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00"
+ bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
+ socket?.outputStream?.write(bytes)
+ }
+
+ fun setLoudSoundReduction(enabled: Boolean) {
+ val hex = "52 1B 00 0${if (enabled) "1" else "0"}"
+ val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
+ socket?.outputStream?.write(bytes)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt
index d45ab0a..20813b1 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt
@@ -72,6 +72,7 @@ class AirPodsNotifications {
const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.aln.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED"
+ const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.aln.AIRPODS_CONNECTION_DETECTED"
}
class EarDetection {
diff --git a/android/app/src/main/java/me/kavishdevar/aln/Window.kt b/android/app/src/main/java/me/kavishdevar/aln/Window.kt
new file mode 100644
index 0000000..d48c9cf
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/aln/Window.kt
@@ -0,0 +1,130 @@
+package me.kavishdevar.aln
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ObjectAnimator
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.PixelFormat
+import android.util.Log
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.DecelerateInterpolator
+import android.widget.ImageButton
+import android.widget.TextView
+import android.widget.VideoView
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.lang.Exception
+
+class Window @SuppressLint("InflateParams") constructor(
+ context: Context
+) {
+ private val mView: View
+ private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams(
+ (context.resources.displayMetrics.widthPixels * 0.95).toInt(),
+ WindowManager.LayoutParams.WRAP_CONTENT, // Display it on top of other application windows
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Don't let it grab the input focus
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // Make the underlying application window visible
+ PixelFormat.TRANSLUCENT
+ )
+ private val mWindowManager: WindowManager
+ init {
+ val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
+ mView = layoutInflater.inflate(R.layout.popup_window, null)
+ mParams.x = 0
+ mParams.y = 0
+
+ mParams.gravity = Gravity.BOTTOM
+ mView.setOnClickListener(View.OnClickListener {
+ close()
+ })
+
+ mView.findViewById(R.id.close_button)
+ .setOnClickListener {
+ close()
+ }
+
+ mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ }
+
+ @SuppressLint("InlinedApi")
+ fun open(name: String = "AirPods Pro") {
+
+ try {
+ if (mView.windowToken == null) {
+ if (mView.parent == null) {
+ // Add the view initially off-screen
+ mWindowManager.addView(mView, mParams)
+ mView.findViewById(R.id.name).text = name
+ val vid = mView.findViewById(R.id.video)
+ vid.setVideoPath("android.resource://me.kavishdevar.aln/" + R.raw.connected)
+ vid.resolveAdjustedSize(vid.width, vid.height)
+ vid.start()
+ vid.setOnCompletionListener {
+ vid.start()
+ }
+
+// receive battery broadcast and set to R.id.battery
+ val batteryText = mView.findViewById(R.id.battery)
+ val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
+ mView.context.registerReceiver(object : BroadcastReceiver() {
+ @SuppressLint("SetTextI18n")
+ override fun onReceive(context: Context, intent: Intent) {
+ val batteryList = intent.getParcelableArrayListExtra("data", Battery::class.java)
+ batteryText.text = batteryList?.get(0)?.level.toString() + "%" + " " + batteryList?.get(0)?.status + " " + batteryList?.get(1)?.level.toString() + "%" + " " + batteryList?.get(1)?.status + " " + batteryList?.get(2)?.level.toString() + "%" + " " + batteryList?.get(2)?.status
+ }
+ }, batteryIntentFilter, Context.RECEIVER_NOT_EXPORTED)
+
+
+ // Slide-up animation
+ val displayMetrics = mView.context.resources.displayMetrics
+ val screenHeight = displayMetrics.heightPixels
+
+ mView.translationY = screenHeight.toFloat() // Start below the screen
+ ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
+ duration = 500 // Animation duration in milliseconds
+ interpolator = DecelerateInterpolator() // Smooth deceleration
+ start()
+ }
+
+ CoroutineScope(MainScope().coroutineContext).launch {
+ delay(12000)
+ close()
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.d("PopupService", e.toString())
+ }
+ }
+
+ fun close() {
+ try {
+ ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
+ duration = 500 // Animation duration in milliseconds
+ interpolator = AccelerateInterpolator() // Smooth acceleration
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ try {
+ mWindowManager.removeView(mView)
+ } catch (e: Exception) {
+ Log.d("PopupService", e.toString())
+ }
+ }
+ })
+ start()
+ }
+ } catch (e: Exception) {
+ Log.d("PopupService", e.toString())
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt
index 4bd54f7..f655f4a 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/ui/theme/Theme.kt
@@ -20,7 +20,6 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
-
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
diff --git a/android/app/src/main/res/drawable/button_shape.xml b/android/app/src/main/res/drawable/button_shape.xml
new file mode 100644
index 0000000..9200915
--- /dev/null
+++ b/android/app/src/main/res/drawable/button_shape.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/close.xml b/android/app/src/main/res/drawable/close.xml
new file mode 100644
index 0000000..b604725
--- /dev/null
+++ b/android/app/src/main/res/drawable/close.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/shape.xml b/android/app/src/main/res/drawable/shape.xml
new file mode 100644
index 0000000..91c5c9f
--- /dev/null
+++ b/android/app/src/main/res/drawable/shape.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/popup_window.xml b/android/app/src/main/res/layout/popup_window.xml
new file mode 100644
index 0000000..4e55f44
--- /dev/null
+++ b/android/app/src/main/res/layout/popup_window.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/raw/connected.mp4 b/android/app/src/main/res/raw/connected.mp4
new file mode 100644
index 0000000..c3d9874
Binary files /dev/null and b/android/app/src/main/res/raw/connected.mp4 differ
diff --git a/android/app/src/main/res/raw/connected_uncropped.mp4 b/android/app/src/main/res/raw/connected_uncropped.mp4
new file mode 100644
index 0000000..e7e74ae
Binary files /dev/null and b/android/app/src/main/res/raw/connected_uncropped.mp4 differ
diff --git a/android/app/src/main/res/raw/first_uncropped.mp4 b/android/app/src/main/res/raw/first_uncropped.mp4
new file mode 100644
index 0000000..5e57c0e
Binary files /dev/null and b/android/app/src/main/res/raw/first_uncropped.mp4 differ
diff --git a/android/app/src/main/res/raw/loop_uncropped.mp4 b/android/app/src/main/res/raw/loop_uncropped.mp4
new file mode 100644
index 0000000..14e8efb
Binary files /dev/null and b/android/app/src/main/res/raw/loop_uncropped.mp4 differ
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index e99426a..6d6d0f2 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -3,15 +3,16 @@ accompanistPermissions = "0.36.0"
agp = "8.7.2"
hiddenapibypass = "4.3"
kotlin = "2.0.0"
-coreKtx = "1.13.1"
+coreKtx = "1.15.0"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
-lifecycleRuntimeKtx = "2.8.6"
-activityCompose = "1.9.2"
-composeBom = "2024.09.03"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.9.3"
+composeBom = "2024.11.00"
annotations = "26.0.0"
-navigationCompose = "2.8.2"
+navigationCompose = "2.8.4"
+constraintlayout = "2.2.0"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -32,6 +33,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
index 4652b27..8601bf4 100644
--- a/android/settings.gradle.kts
+++ b/android/settings.gradle.kts
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven("https://jitpack.io")
}
}