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