diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 34bc80f..a9f9e93 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + - + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt index 0a924e0..9a5cf14 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt @@ -6,6 +6,9 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothSocket import android.content.BroadcastReceiver import android.content.Context @@ -23,6 +26,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.lsposed.hiddenapibypass.HiddenApiBypass +private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV" +private const val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1 +private const val APPLE = 0x004C +const val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" +const val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" +private const val PACKAGE_ASI = "com.google.android.settings.intelligence" +private const val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" +//private const val COMPANION_TYPE_NONE = "COMPANION_NONE" +//const val VENDOR_RESULT_CODE_COMMAND_ANDROID = "+ANDROID" + class AirPodsService : Service() { inner class LocalBinder : Binder() { fun getService(): AirPodsService = this@AirPodsService @@ -93,7 +106,7 @@ class AirPodsService : Service() { fun getANC(): Int { return ancNotification.status } - +// // private fun buildBatteryText(battery: List): String { // val left = battery[0] // val right = battery[1] @@ -118,6 +131,165 @@ class AirPodsService : Service() { return notificationBuilder.build() } + fun disconnectAudio(context: Context, device: BluetoothDevice?) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) + method.invoke(proxy, device) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) { } + }, BluetoothProfile.A2DP) + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) + method.invoke(proxy, device) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) { } + }, BluetoothProfile.HEADSET) + } + + fun connectAudio(context: Context, device: BluetoothDevice?) { + val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + method.invoke(proxy, device) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) { } + }, BluetoothProfile.A2DP) + + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + method.invoke(proxy, device) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) { } + }, BluetoothProfile.HEADSET) + } + + fun updatePodsStatus(device: BluetoothDevice, batteryList: List) { + var batteryUnified = 0 + var batteryUnifiedArg = 0 + + // Handle each Battery object from batteryList +// batteryList.forEach { battery -> +// when (battery.getComponentName()) { +// "LEFT" -> { +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 10, battery.level.toString().toByteArray()) +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 13, battery.getStatusName()?.uppercase()?.toByteArray()) +// } +// "RIGHT" -> { +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 11, battery.level.toString().toByteArray()) +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 14, battery.getStatusName()?.uppercase()?.toByteArray()) +// } +// "CASE" -> { +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 12, battery.level.toString().toByteArray()) +// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 15, battery.getStatusName()?.uppercase()?.toByteArray()) +// } +// } +// } + + + // Sending broadcast for battery update + broadcastVendorSpecificEventIntent( + VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV, + APPLE, + BluetoothHeadset.AT_CMD_TYPE_SET, + batteryUnified, + batteryUnifiedArg, + device + ) + } + + @Suppress("SameParameterValue") + @SuppressLint("MissingPermission") + private fun broadcastVendorSpecificEventIntent( + command: String, + companyId: Int, + commandType: Int, + batteryUnified: Int, + batteryUnifiedArg: Int, + device: BluetoothDevice + ) { + val arguments = arrayOf( + 1, // Number of key(IndicatorType)/value pairs + VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level + batteryUnifiedArg // Battery Level + ) + + val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply { + putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, command) + putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, commandType) + putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments) + putExtra(BluetoothDevice.EXTRA_DEVICE, device) + putExtra(BluetoothDevice.EXTRA_NAME, device.name) + addCategory(BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY + "." + companyId.toString()) + } + sendBroadcast(intent) + + val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply { + putExtra(BluetoothDevice.EXTRA_DEVICE, device) + putExtra(EXTRA_BATTERY_LEVEL, batteryUnified) + } + sendBroadcast(batteryIntent) + + val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).setPackage(PACKAGE_ASI).apply { + putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent) + } + sendBroadcast(statusIntent) + } + + + fun setName(name: String) { + val nameBytes = name.toByteArray() + val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01, + nameBytes.size.toByte(), 0x00) + nameBytes + socket?.outputStream?.write(bytes) + socket?.outputStream?.flush() + val hex = bytes.joinToString(" ") { "%02X".format(it) } + Log.d("AirPodsService", "setName: $name, sent packet: $hex") + } + @SuppressLint("MissingPermission", "InlinedApi") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -133,6 +305,7 @@ class AirPodsService : Service() { HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + socket = HiddenApiBypass.newInstance(BluetoothSocket::class.java, 3, true, true, device, 0x1001, uuid) as BluetoothSocket? try { socket?.connect() @@ -150,8 +323,9 @@ class AirPodsService : Service() { MediaController.initialize(audioManager) val buffer = ByteArray(1024) val bytesRead = it.inputStream.read(buffer) - val data = buffer.copyOfRange(0, bytesRead) + var data: ByteArray = byteArrayOf() if (bytesRead > 0) { + data = buffer.copyOfRange(0, bytesRead) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { putExtra("data", buffer.copyOfRange(0, bytesRead)) }) @@ -159,6 +333,15 @@ class AirPodsService : Service() { 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 { @@ -169,7 +352,7 @@ class AirPodsService : Service() { putExtra("data", bytes) }) Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}") - var inEar = false + var justEnabledA2dp = false val earReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val data = intent.getByteArrayExtra("data") @@ -179,10 +362,52 @@ class AirPodsService : Service() { } else { data[0] == 0x00.toByte() && data[1] == 0x00.toByte() } - if (inEar) { - MediaController.sendPlay() + + val newInEarData = listOf(data[0] == 0x00.toByte(), data[1] == 0x00.toByte()) + if (newInEarData.contains(true) && inEarData == listOf(false, false)) { + connectAudio(this@AirPodsService, device) + justEnabledA2dp = true + val bluetoothAdapter = this@AirPodsService.getSystemService(BluetoothManager::class.java).adapter + bluetoothAdapter.getProfileProxy( + this@AirPodsService, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected( + profile: Int, + proxy: BluetoothProfile + ) { + if (profile == BluetoothProfile.A2DP) { + val connectedDevices = + proxy.connectedDevices + if (connectedDevices.isNotEmpty()) { + MediaController.sendPlay() + } + } + bluetoothAdapter.closeProfileProxy( + profile, + proxy + ) + } + + override fun onServiceDisconnected( + profile: Int + ) { + } + } + ,BluetoothProfile.A2DP + ) + } - else { + else if (newInEarData == listOf(false, false)){ + disconnectAudio(this@AirPodsService, device) + } + + inEarData = newInEarData + + if (inEar == true) { + if (!justEnabledA2dp) { + justEnabledA2dp = false + MediaController.sendPlay() + } + } else { MediaController.sendPause() } } @@ -209,18 +434,21 @@ class AirPodsService : Service() { for (battery in batteryNotification.getBattery()) { Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ") } +// updatePodsStatus(device!!, batteryNotification.getBattery()) } else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) { conversationAwarenessNotification.setData(data) sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { putExtra("data", conversationAwarenessNotification.status) }) + + if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { MediaController.startSpeaking() - } - else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { + } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { MediaController.stopSpeaking() } + Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}") } else { } @@ -228,6 +456,9 @@ class AirPodsService : Service() { } Log.d("AirPods Service", "Socket closed") isRunning = false + this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE) + socket?.close() + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) } } } 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 e98d8c2..e815107 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.SolidColor @@ -153,8 +154,9 @@ fun BatteryView() { @Composable fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?, navController: NavController) { - var deviceName by remember { mutableStateOf(TextFieldValue(device?.name ?: "AirPods Pro (fallback, should never show up)")) } - + 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 @@ -176,25 +178,29 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice? }) } } - BatteryView() 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) } + onValueChange = { + deviceName = TextFieldValue(it) + sharedPreferences.edit().putString("name", it).apply() + service.setName(it) + } ) Spacer(modifier = Modifier.height(32.dp)) - NoiseControlSettings(service = service) Spacer(modifier = Modifier.height(16.dp)) AudioSettings(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)) @@ -217,7 +223,6 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice? // } Spacer(modifier = Modifier.height(16.dp)) - Row ( modifier = Modifier .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)) @@ -767,11 +772,17 @@ fun StyledTextField( value: String, onValueChange: (String) -> Unit ) { + var isFocused by remember { mutableStateOf(false) } + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - val cursorColor = if (isDarkTheme) Color.White else Color.Black + val cursorColor = if (isFocused) { // Show cursor only when focused + if (isDarkTheme) Color.White else Color.Black + } else { + Color.Transparent // Hide cursor when not focused + } Row( verticalAlignment = Alignment.CenterVertically, @@ -781,14 +792,14 @@ fun StyledTextField( .background( backgroundColor, RoundedCornerShape(14.dp) - ) // Dynamic background based on theme + ) .padding(horizontal = 16.dp, vertical = 8.dp) ) { Text( text = name, style = TextStyle( fontSize = 16.sp, - color = textColor // Text color based on theme + color = textColor ) ) @@ -796,10 +807,10 @@ fun StyledTextField( value = value, onValueChange = onValueChange, textStyle = TextStyle( - color = textColor, // Dynamic text color + color = textColor, fontSize = 16.sp, ), - cursorBrush = SolidColor(cursorColor), // Dynamic cursor color + cursorBrush = SolidColor(cursorColor), // Dynamic cursor color based on focus decorationBox = { innerTextField -> Row( verticalAlignment = Alignment.CenterVertically, @@ -809,8 +820,11 @@ fun StyledTextField( } }, modifier = Modifier - .fillMaxWidth() // Ensures text field takes remaining available space - .padding(start = 8.dp), // Padding to adjust spacing between text field and icon, + .fillMaxWidth() + .padding(start = 8.dp) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused // Update focus state + } ) } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt index 4bd4995..bb4e936 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt @@ -167,7 +167,7 @@ fun DebugScreen(navController: NavController) { Row( modifier = Modifier .fillMaxWidth() - .background(Color(0xFF1C1B20)), + .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7)), verticalAlignment = Alignment.CenterVertically ) { val packet = remember { mutableStateOf(TextFieldValue("")) } 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 fd3e609..bd76452 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -1,13 +1,17 @@ package me.kavishdevar.aln import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.ServiceConnection +import android.os.Build import android.os.Bundle import android.os.IBinder import android.os.ParcelUuid @@ -30,6 +34,7 @@ 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 @@ -53,8 +58,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { + val topAppBarTitle = remember { mutableStateOf("AirPods Pro") } ALNTheme { Scaffold ( containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color( @@ -66,7 +71,7 @@ class MainActivity : ComponentActivity() { CenterAlignedTopAppBar( title = { Text( - text = "AirPods Pro Settings", + text = topAppBarTitle.value, color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black, ) }, @@ -80,7 +85,7 @@ class MainActivity : ComponentActivity() { ) } ) { innerPadding -> - Main(innerPadding) + Main(innerPadding, topAppBarTitle) } } } @@ -90,7 +95,7 @@ class MainActivity : ComponentActivity() { @SuppressLint("MissingPermission") @OptIn(ExperimentalPermissionsApi::class) @Composable -fun Main(paddingValues: PaddingValues) { +fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState) { val bluetoothConnectPermissionState = rememberPermissionState( permission = "android.permission.BLUETOOTH_CONNECT" ) @@ -100,38 +105,21 @@ fun Main(paddingValues: PaddingValues) { val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val bluetoothManager = getSystemService(context, BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager?.adapter - val devices = bluetoothAdapter?.bondedDevices val airpodsDevice = remember { mutableStateOf(null) } - + val airPodsService = remember { mutableStateOf(null) } val navController = rememberNavController() - if (devices != null) { - for (device in devices) { - 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 - if (context.getSystemService(AirPodsService::class.java) == null || context.getSystemService(AirPodsService::class.java)?.isRunning != true) { - context.startService(Intent(context, AirPodsService::class.java).apply { - putExtra("device", device) - }) - } - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) { } - }, BluetoothProfile.A2DP) - } + val disconnectReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + navController.navigate("notConnected") } } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(disconnectReceiver, IntentFilter(AirPodsNotifications.AIRPODS_DISCONNECTED), + Context.RECEIVER_NOT_EXPORTED) + } - val airPodsService = remember { mutableStateOf(null) } - + // Service connection for AirPodsService val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName, service: IBinder) { val binder = service as AirPodsService.LocalBinder @@ -144,16 +132,88 @@ fun Main(paddingValues: PaddingValues) { } } - val intent = Intent(context, AirPodsService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + // 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)?.isRunning != 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) + } + } + } + + // BroadcastReceiver to listen for connection state changes + val bluetoothReceiver = remember { + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent?.action + val device = intent?.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + if (action == BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) { + when (intent.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)) { + BluetoothAdapter.STATE_CONNECTED -> { + if (device?.uuids?.contains(uuid) == true) { + airpodsDevice.value = device + checkIfAirPodsConnected() + } + } + BluetoothAdapter.STATE_DISCONNECTED -> { + if (device?.uuids?.contains(uuid) == true) { + airpodsDevice.value = null + // Show not connected screen when AirPods disconnect + navController.navigate("notConnected") + } + } + } + } + } + } + } + + // Register the receiver in LaunchedEffect + LaunchedEffect(Unit) { + val filter = IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_CONNECTION_STATE_CHANGED) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(bluetoothReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } + + // Initial check for AirPods connection + checkIfAirPodsConnected() + } + + // UI logic NavHost( navController = navController, startDestination = "notConnected", - enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) }, // Slide in from the right - exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) }, // Slide out to the left - popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) }, // Slide in from the left - popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) } // Slide out to the right - ){ + enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) }, + exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) }, + popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) } + ) { composable("notConnected") { Text("Not Connected...") } @@ -169,17 +229,17 @@ fun Main(paddingValues: PaddingValues) { DebugScreen(navController = navController) } } + + // Automatically navigate to settings screen if AirPods are connected if (airpodsDevice.value != null) { LaunchedEffect(Unit) { navController.navigate("settings") { popUpTo("notConnected") { inclusive = true } } } - } - else { + } else { Text("No AirPods connected") } - return } else { // Permission is not granted, request it Column ( 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 62e7e62..82f3374 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt @@ -67,6 +67,7 @@ class AirPodsNotifications { const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA" 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" } class EarDetection {