android: disable audio profiles when not in ear; add a debug screen

This commit is contained in:
Kavish Devar
2024-10-15 11:50:20 +05:30
parent f0c8a4965a
commit 4fd2717413
6 changed files with 388 additions and 64 deletions

View File

@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -36,6 +42,18 @@
android:exported="true" android:exported="true"
android:foregroundServiceType="connectedDevice" android:foregroundServiceType="connectedDevice"
android:permission="android.permission.BLUETOOTH_CONNECT" /> android:permission="android.permission.BLUETOOTH_CONNECT" />
</application>
<!-- <receiver android:name=".StartupReceiver"-->
<!-- android:exported="true">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.bluetooth.device.action.ACL_CONNECTED" />-->
<!-- <action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.BOND_STATE_CHANGED" />-->
<!-- <action android:name="android.bluetooth.device.action.NAME_CHANGED" />-->
<!-- <action android:name="android.intent.action.BOOT_COMPLETED" />-->
<!-- <action android:name="android.bluetooth.adapter.action.STATE_CHANGED" />-->
<!-- </intent-filter>-->
<!-- </receiver>-->
</application>
</manifest> </manifest>

View File

@@ -6,6 +6,9 @@ import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -23,6 +26,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass 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() { class AirPodsService : Service() {
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService fun getService(): AirPodsService = this@AirPodsService
@@ -93,7 +106,7 @@ class AirPodsService : Service() {
fun getANC(): Int { fun getANC(): Int {
return ancNotification.status return ancNotification.status
} }
//
// private fun buildBatteryText(battery: List<Battery>): String { // private fun buildBatteryText(battery: List<Battery>): String {
// val left = battery[0] // val left = battery[0]
// val right = battery[1] // val right = battery[1]
@@ -118,6 +131,165 @@ class AirPodsService : Service() {
return notificationBuilder.build() return notificationBuilder.build()
} }
fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}
fun connectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.A2DP)
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
try {
val method = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device)
} catch (e: Exception) {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) { }
}, BluetoothProfile.HEADSET)
}
fun updatePodsStatus(device: BluetoothDevice, batteryList: List<Battery>) {
var batteryUnified = 0
var batteryUnifiedArg = 0
// Handle each Battery object from batteryList
// batteryList.forEach { battery ->
// when (battery.getComponentName()) {
// "LEFT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 10, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 13, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "RIGHT" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 11, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 14, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// "CASE" -> {
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 12, battery.level.toString().toByteArray())
// HiddenApiBypass.invoke(BluetoothDevice::class.java, device, "setMetadata", 15, battery.getStatusName()?.uppercase()?.toByteArray())
// }
// }
// }
// Sending broadcast for battery update
broadcastVendorSpecificEventIntent(
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV,
APPLE,
BluetoothHeadset.AT_CMD_TYPE_SET,
batteryUnified,
batteryUnifiedArg,
device
)
}
@Suppress("SameParameterValue")
@SuppressLint("MissingPermission")
private fun broadcastVendorSpecificEventIntent(
command: String,
companyId: Int,
commandType: Int,
batteryUnified: Int,
batteryUnifiedArg: Int,
device: BluetoothDevice
) {
val arguments = arrayOf(
1, // Number of key(IndicatorType)/value pairs
VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL, // IndicatorType: Battery Level
batteryUnifiedArg // Battery Level
)
val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply {
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, command)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, commandType)
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments)
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(BluetoothDevice.EXTRA_NAME, device.name)
addCategory(BluetoothHeadset.VENDOR_SPECIFIC_HEADSET_EVENT_COMPANY_ID_CATEGORY + "." + companyId.toString())
}
sendBroadcast(intent)
val batteryIntent = Intent(ACTION_BATTERY_LEVEL_CHANGED).apply {
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(EXTRA_BATTERY_LEVEL, batteryUnified)
}
sendBroadcast(batteryIntent)
val statusIntent = Intent(ACTION_ASI_UPDATE_BLUETOOTH_DATA).setPackage(PACKAGE_ASI).apply {
putExtra(ACTION_BATTERY_LEVEL_CHANGED, intent)
}
sendBroadcast(statusIntent)
}
fun setName(name: String) {
val nameBytes = name.toByteArray()
val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01,
nameBytes.size.toByte(), 0x00) + nameBytes
socket?.outputStream?.write(bytes)
socket?.outputStream?.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPodsService", "setName: $name, sent packet: $hex")
}
@SuppressLint("MissingPermission", "InlinedApi") @SuppressLint("MissingPermission", "InlinedApi")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -133,6 +305,7 @@ class AirPodsService : Service() {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
socket = HiddenApiBypass.newInstance(BluetoothSocket::class.java, 3, true, true, device, 0x1001, uuid) as BluetoothSocket? socket = HiddenApiBypass.newInstance(BluetoothSocket::class.java, 3, true, true, device, 0x1001, uuid) as BluetoothSocket?
try { try {
socket?.connect() socket?.connect()
@@ -150,8 +323,9 @@ class AirPodsService : Service() {
MediaController.initialize(audioManager) MediaController.initialize(audioManager)
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer) val bytesRead = it.inputStream.read(buffer)
val data = buffer.copyOfRange(0, bytesRead) var data: ByteArray = byteArrayOf()
if (bytesRead > 0) { if (bytesRead > 0) {
data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead)) putExtra("data", buffer.copyOfRange(0, bytesRead))
}) })
@@ -159,6 +333,15 @@ class AirPodsService : Service() {
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
Log.d("AirPods Data", "Data received: $formattedHex") Log.d("AirPods Data", "Data received: $formattedHex")
} }
else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
var inEar = false
var inEarData = listOf<Boolean>()
if (earDetectionNotification.isEarDetectionData(data)) { if (earDetectionNotification.isEarDetectionData(data)) {
earDetectionNotification.setStatus(data) earDetectionNotification.setStatus(data)
sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
@@ -169,7 +352,7 @@ class AirPodsService : Service() {
putExtra("data", bytes) putExtra("data", bytes)
}) })
Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}") Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}")
var inEar = false var justEnabledA2dp = false
val earReceiver = object : BroadcastReceiver() { val earReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val data = intent.getByteArrayExtra("data") val data = intent.getByteArrayExtra("data")
@@ -179,10 +362,52 @@ class AirPodsService : Service() {
} else { } else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte() 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() MediaController.sendPause()
} }
} }
@@ -209,18 +434,21 @@ class AirPodsService : Service() {
for (battery in batteryNotification.getBattery()) { for (battery in batteryNotification.getBattery()) {
Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ") Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ")
} }
// updatePodsStatus(device!!, batteryNotification.getBattery())
} }
else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) { else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) {
conversationAwarenessNotification.setData(data) conversationAwarenessNotification.setData(data)
sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
putExtra("data", conversationAwarenessNotification.status) putExtra("data", conversationAwarenessNotification.status)
}) })
if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
MediaController.startSpeaking() MediaController.startSpeaking()
} } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
MediaController.stopSpeaking() MediaController.stopSpeaking()
} }
Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}") Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}")
} }
else { } else { }
@@ -228,6 +456,9 @@ class AirPodsService : Service() {
} }
Log.d("AirPods Service", "Socket closed") Log.d("AirPods Service", "Socket closed")
isRunning = false isRunning = false
this@AirPodsService.stopForeground(STOP_FOREGROUND_REMOVE)
socket?.close()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
} }
} }
} }

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -153,8 +154,9 @@ fun BatteryView() {
@Composable @Composable
fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?, fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?,
navController: NavController) { 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() val verticalScrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -176,25 +178,29 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?
}) })
} }
} }
BatteryView()
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
if (service != null) { if (service != null) {
BatteryView()
Spacer(modifier = Modifier.height(32.dp))
StyledTextField( StyledTextField(
name = "Name", name = "Name",
value = deviceName.text, 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)) Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service) NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AudioSettings(service = service, sharedPreferences = sharedPreferences) AudioSettings(service = service, sharedPreferences = sharedPreferences)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(name = "Automatic Ear Detection", service = service, functionName = "setEarDetection", sharedPreferences = sharedPreferences, true) IndependentToggle(name = "Automatic Ear Detection", service = service, functionName = "setEarDetection", sharedPreferences = sharedPreferences, true)
// Spacer(modifier = Modifier.height(16.dp)) // Spacer(modifier = Modifier.height(16.dp))
@@ -217,7 +223,6 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?
// } // }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row ( Row (
modifier = Modifier modifier = Modifier
.background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)) .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp))
@@ -767,11 +772,17 @@ fun StyledTextField(
value: String, value: String,
onValueChange: (String) -> Unit onValueChange: (String) -> Unit
) { ) {
var isFocused by remember { mutableStateOf(false) }
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5
val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -781,14 +792,14 @@ fun StyledTextField(
.background( .background(
backgroundColor, backgroundColor,
RoundedCornerShape(14.dp) RoundedCornerShape(14.dp)
) // Dynamic background based on theme )
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
Text( Text(
text = name, text = name,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor // Text color based on theme color = textColor
) )
) )
@@ -796,10 +807,10 @@ fun StyledTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
textStyle = TextStyle( textStyle = TextStyle(
color = textColor, // Dynamic text color color = textColor,
fontSize = 16.sp, fontSize = 16.sp,
), ),
cursorBrush = SolidColor(cursorColor), // Dynamic cursor color cursorBrush = SolidColor(cursorColor), // Dynamic cursor color based on focus
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -809,8 +820,11 @@ fun StyledTextField(
} }
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() // Ensures text field takes remaining available space .fillMaxWidth()
.padding(start = 8.dp), // Padding to adjust spacing between text field and icon, .padding(start = 8.dp)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused // Update focus state
}
) )
} }
} }

View File

@@ -167,7 +167,7 @@ fun DebugScreen(navController: NavController) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0xFF1C1B20)), .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val packet = remember { mutableStateOf(TextFieldValue("")) } val packet = remember { mutableStateOf(TextFieldValue("")) }

View File

@@ -1,13 +1,17 @@
package me.kavishdevar.aln package me.kavishdevar.aln
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.os.ParcelUuid import android.os.ParcelUuid
@@ -30,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,8 +58,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
val topAppBarTitle = remember { mutableStateOf("AirPods Pro") }
ALNTheme { ALNTheme {
Scaffold ( Scaffold (
containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color( containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(
@@ -66,7 +71,7 @@ class MainActivity : ComponentActivity() {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text(
text = "AirPods Pro Settings", text = topAppBarTitle.value,
color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black, color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black,
) )
}, },
@@ -80,7 +85,7 @@ class MainActivity : ComponentActivity() {
) )
} }
) { innerPadding -> ) { innerPadding ->
Main(innerPadding) Main(innerPadding, topAppBarTitle)
} }
} }
} }
@@ -90,7 +95,7 @@ class MainActivity : ComponentActivity() {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun Main(paddingValues: PaddingValues) { fun Main(paddingValues: PaddingValues, topAppBarTitle: MutableState<String>) {
val bluetoothConnectPermissionState = rememberPermissionState( val bluetoothConnectPermissionState = rememberPermissionState(
permission = "android.permission.BLUETOOTH_CONNECT" permission = "android.permission.BLUETOOTH_CONNECT"
) )
@@ -100,38 +105,21 @@ fun Main(paddingValues: PaddingValues) {
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
val bluetoothManager = getSystemService(context, BluetoothManager::class.java) val bluetoothManager = getSystemService(context, BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager?.adapter val bluetoothAdapter = bluetoothManager?.adapter
val devices = bluetoothAdapter?.bondedDevices
val airpodsDevice = remember { mutableStateOf<BluetoothDevice?>(null) } val airpodsDevice = remember { mutableStateOf<BluetoothDevice?>(null) }
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val navController = rememberNavController() val navController = rememberNavController()
if (devices != null) { val disconnectReceiver = object : BroadcastReceiver() {
for (device in devices) { override fun onReceive(context: Context?, intent: Intent?) {
if (device.uuids.contains(uuid)) { navController.navigate("notConnected")
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)
}
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(disconnectReceiver, IntentFilter(AirPodsNotifications.AIRPODS_DISCONNECTED),
Context.RECEIVER_NOT_EXPORTED)
}
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } // Service connection for AirPodsService
val serviceConnection = object : ServiceConnection { val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) { override fun onServiceConnected(name: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder val binder = service as AirPodsService.LocalBinder
@@ -144,16 +132,88 @@ fun Main(paddingValues: PaddingValues) {
} }
} }
val intent = Intent(context, AirPodsService::class.java) // Function to check if AirPods are connected
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) 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>(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( NavHost(
navController = navController, navController = navController,
startDestination = "notConnected", startDestination = "notConnected",
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) }, // Slide in from the right enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(300)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) }, // Slide out to the left exitTransition = { slideOutHorizontally(targetOffsetX = { -it }, animationSpec = tween(300)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) }, // Slide in from the left popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }, animationSpec = tween(300)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) } // Slide out to the right popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(300)) }
){ ) {
composable("notConnected") { composable("notConnected") {
Text("Not Connected...") Text("Not Connected...")
} }
@@ -169,17 +229,17 @@ fun Main(paddingValues: PaddingValues) {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
} }
// Automatically navigate to settings screen if AirPods are connected
if (airpodsDevice.value != null) { if (airpodsDevice.value != null) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
navController.navigate("settings") { navController.navigate("settings") {
popUpTo("notConnected") { inclusive = true } popUpTo("notConnected") { inclusive = true }
} }
} }
} } else {
else {
Text("No AirPods connected") Text("No AirPods connected")
} }
return
} else { } else {
// Permission is not granted, request it // Permission is not granted, request it
Column ( Column (

View File

@@ -67,6 +67,7 @@ class AirPodsNotifications {
const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA" const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA"
const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA" const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.aln.CA_DATA" const val CA_DATA = "me.kavishdevar.aln.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED"
} }
class EarDetection { class EarDetection {