diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4d4491c..dd08573 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + id("kotlin-parcelize") } android { @@ -10,8 +11,8 @@ android { defaultConfig { applicationId = "me.kavishdevar.aln" - minSdk = 22 - targetSdk = 34 + minSdk = 28 + targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -40,7 +41,8 @@ android { } dependencies { - + implementation(libs.accompanist.permissions) + implementation(libs.hiddenapibypass) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 11f6af6..52ec506 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -20,10 +20,16 @@ android:theme="@style/Theme.ALN"> - + + \ 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 new file mode 100644 index 0000000..bc94c8a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt @@ -0,0 +1,153 @@ +package me.kavishdevar.aln + +import android.annotation.SuppressLint +import android.app.Service +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.content.Intent +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.os.ParcelUuid +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.lsposed.hiddenapibypass.HiddenApiBypass + +class AirPodsService : Service() { + inner class LocalBinder : Binder() { + fun getService(): AirPodsService = this@AirPodsService + } + + override fun onBind(intent: Intent?): IBinder { + return LocalBinder() + } + + var isRunning: Boolean = false + private var socket: BluetoothSocket? = null + + 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 setAdaptiveStrength(strength: Int) { + val bytes = byteArrayOf(0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00) + val hexString = bytes.joinToString(" ") { "%02X".format(it) } + Log.d("AirPodsService", "Adaptive Strength: $hexString") + socket?.outputStream?.write(bytes) + socket?.outputStream?.flush() + } + + @SuppressLint("MissingPermission") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (isRunning) { + return START_STICKY + } + isRunning = 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(Notifications.AIRPODS_CONNECTED)) + it.outputStream.flush() + + CoroutineScope(Dispatchers.IO).launch { + val earDetectionNotification = Notifications.EarDetection() + val ancNotification = Notifications.ANC() + val batteryNotification = Notifications.BatteryNotification() + val conversationAwarenessNotification = Notifications.ConversationalAwarenessNotification() + + while (socket?.isConnected == true) { + socket?.let { + val buffer = ByteArray(1024) + val bytesRead = it.inputStream.read(buffer) + val data = buffer.copyOfRange(0, bytesRead) + if (bytesRead > 0) { + sendBroadcast(Intent(Notifications.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") + } + if (earDetectionNotification.isEarDetectionData(data)) { + earDetectionNotification.setStatus(data) + sendBroadcast(Intent(Notifications.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]}") + } + else if (ancNotification.isANCData(data)) { + ancNotification.setStatus(data) + sendBroadcast(Intent(Notifications.ANC_DATA).apply { + putExtra("data", ancNotification.status) + }) + Log.d("AirPods Parser", "ANC: ${ancNotification.status}") + } + else if (batteryNotification.isBatteryData(data)) { + batteryNotification.setBattery(data) + sendBroadcast(Intent(Notifications.BATTERY_DATA).apply { + putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) + }) + for (battery in batteryNotification.getBattery()) { + Log.d("AirPods Parser", "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% ") + } + } + else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) { + conversationAwarenessNotification.setData(data) + sendBroadcast(Intent(Notifications.CA_DATA).apply { + putExtra("data", conversationAwarenessNotification.status) + }) + Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}") + } + else { } + } + } + Log.d("AirPods Service", "Socket closed") + isRunning = false + } + } + } + catch (e: Exception) { + Log.e("AirPodsSettingsScreen", "Error connecting to device: ${e.message}") + } + return START_STICKY + } + + override fun onDestroy() { + super.onDestroy() + socket?.close() + isRunning = false + } +} \ 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 0d9d15f..9fa8876 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -3,14 +3,21 @@ package me.kavishdevar.aln import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager -import android.content.res.Configuration +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 import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.annotation.RequiresApi import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -37,6 +44,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -45,6 +53,7 @@ import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -53,43 +62,49 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.imageResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat.getSystemService +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 me.kavishdevar.aln.ui.theme.ALNTheme class MainActivity : ComponentActivity() { - @SuppressLint("MissingPermission") - @RequiresApi(Build.VERSION_CODES.M) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val address = mutableStateOf("28:2D:7F:C2:05:5B") - val bluetoothManager: BluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - val device = bluetoothAdapter.getRemoteDevice(address.value) setContent { ALNTheme { - Scaffold { innerPadding -> - AirPodsSettingsScreen(innerPadding, device) + Scaffold ( + containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000) else Color(0xFFFFFFFF) + ) { innerPadding -> + Main(innerPadding) } } } } } +@SuppressLint("UseOfNonLambdaOffsetOverload") @Composable fun StyledSwitch( checked: Boolean, @@ -131,7 +146,7 @@ fun StyledTextField( ) { val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 - val backgroundColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFFFFFFF) + val backgroundColor = if (isDarkTheme) Color(0xFF252525) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val cursorColor = if (isDarkTheme) Color.White else Color.Black @@ -178,7 +193,7 @@ fun StyledTextField( } @Composable -fun BatteryIndicator(batteryPercentage: Int) { +fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) { val batteryOutlineColor = Color(0xFFBFBFBF) // Light gray outline val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C) val batteryTextColor = MaterialTheme.colorScheme.onSurface @@ -187,7 +202,7 @@ fun BatteryIndicator(batteryPercentage: Int) { val batteryWidth = 30.dp val batteryHeight = 15.dp val batteryCornerRadius = 4.dp - val tipWidth = 3.dp + val tipWidth = 5.dp val tipHeight = batteryHeight * 0.3f Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -204,7 +219,6 @@ fun BatteryIndicator(batteryPercentage: Int) { .height(batteryHeight) .border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius)) ) { - // Battery Fill Box( modifier = Modifier .fillMaxHeight() @@ -212,6 +226,21 @@ fun BatteryIndicator(batteryPercentage: Int) { .width(batteryWidth * (batteryPercentage / 100f)) .background(batteryFillColor, RoundedCornerShape(2.dp)) ) + if (charging) { + Box( + modifier = Modifier + .fillMaxSize(), // Take up the entire size of the outer Box + contentAlignment = Alignment.Center // Center the charging bolt within the Box + ) { + Text( + text = "\uDBC0\uDEE6", + fontSize = 12.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = Color.White, + modifier = Modifier.align(Alignment.Center) + ) + } + } } // Battery Tip (Protrusion) @@ -224,9 +253,9 @@ fun BatteryIndicator(batteryPercentage: Int) { batteryOutlineColor, RoundedCornerShape( topStart = 0.dp, - topEnd = 5.dp, - bottomStart = 5.dp, - bottomEnd = 4.dp + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 12.dp ) ) ) @@ -241,41 +270,165 @@ fun BatteryIndicator(batteryPercentage: Int) { } } -@SuppressLint("MissingPermission", "NewApi") +@SuppressLint("MissingPermission") +@OptIn(ExperimentalPermissionsApi::class) @Composable -fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?) { - var deviceName by remember { mutableStateOf(TextFieldValue(device?.name ?: "Kavish's AirPods Pro (Fallback)")) } - val channel = device?.createL2capChannel(0x1001) - val connected = remember { mutableStateOf(false) } - try { - channel?.connect() - channel?.let { it -> - var message = "00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00" - var bytes = message.split(" ").map { it.toInt(16).toByte() }.toByteArray() - it.outputStream.write(bytes) - Log.d("AirPodsSettingsScreen", "Message sent: $message") +fun Main(paddingValues: PaddingValues) { + val bluetoothConnectPermissionState = rememberPermissionState( + permission = "android.permission.BLUETOOTH_CONNECT" + ) - message = "04 00 04 00 4d 00 ff 00 00 00 00 00 00 00" - bytes = message.split(" ").map { it.toInt(16).toByte() }.toByteArray() - it.outputStream.write(bytes) - Log.d("AirPodsSettingsScreen", "Message sent: $message") + 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 devices = bluetoothAdapter?.bondedDevices + val airpodsDevice = remember { mutableStateOf(null) } + 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) + } - message = "04 00 04 00 0F 00 FF FF FE FF" - bytes = message.split(" ").map { it.toInt(16).toByte() }.toByteArray() - it.outputStream.write(bytes) - Log.d("AirPodsSettingsScreen", "Message sent: $message") - connected.value = true - it.outputStream.flush() + override fun onServiceDisconnected(profile: Int) { } + }, BluetoothProfile.A2DP) + } + } + } + + val airPodsService = remember { mutableStateOf(null) } + + 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 + } + } + val intent = Intent(context, AirPodsService::class.java) + context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + + if (airpodsDevice.value != null) + { + AirPodsSettingsScreen( + paddingValues, + airpodsDevice.value, + service = airPodsService.value + ) + } + else { + Text("No AirPods connected") + } + return + } else { + // Permission is not granted, request it + Column ( + modifier = Modifier.padding(24.dp), + ){ + val textToShow = if (bluetoothConnectPermissionState.status.shouldShowRationale) { + // If the user has denied the permission but not permanently, explain why it's needed. + "The BLUETOOTH_CONNECT permission is important for this app. Please grant it to proceed." + } else { + // If the user has permanently denied the permission, inform them to enable it in settings. + "BLUETOOTH_CONNECT permission required for this feature. Please enable it in settings." + } + Text(textToShow) + Button(onClick = { bluetoothConnectPermissionState.launchPermissionRequest() }) { + Text("Request permission") + } } } - catch (e: Exception) { - Log.e("AirPodsSettingsScreen", "Error connecting to device: ${e.message}") +} + +@Composable +fun BatteryView() { + val batteryStatus = remember { mutableStateOf>(listOf()) } + + @Suppress("DEPRECATION") val batteryReceiver = remember { + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + batteryStatus.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableArrayListExtra("data", Battery::class.java) } else { intent.getParcelableArrayListExtra("data") }?.toList() ?: listOf() + } + } } - finally { - channel?.close() + val context = LocalContext.current + + LaunchedEffect(context) { + val batteryIntentFilter = IntentFilter(Notifications.BATTERY_DATA) + context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) } - - Text(text = "Connected ${connected.value}") + + Row { + Column ( + modifier = Modifier + .fillMaxWidth(0.5f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image ( + bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds), + contentDescription = "Buds", + modifier = Modifier + .fillMaxWidth() + .scale(0.50f) + ) + val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT } + val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT } + if ((right?.status == BatteryStatus.CHARGING && left?.status == BatteryStatus.CHARGING) || (left?.status == BatteryStatus.NOT_CHARGING && right?.status == BatteryStatus.NOT_CHARGING)) + { + BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING) + } + else { + Row { + Text(text = "\uDBC6\uDCE5", fontFamily = FontFamily(Font(R.font.sf_pro))) + BatteryIndicator(left?.level ?: 0, left?.status == BatteryStatus.CHARGING) + Spacer(modifier = Modifier.width(16.dp)) + Text(text = "\uDBC6\uDCE8", fontFamily = FontFamily(Font(R.font.sf_pro))) + BatteryIndicator(right?.level ?: 0, right?.status == BatteryStatus.CHARGING) + } + } + } + + Column ( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val case = batteryStatus.value.find { it.component == BatteryComponent.CASE } + + Image( + bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case), + contentDescription = "Case", + modifier = Modifier + .fillMaxWidth() + ) + BatteryIndicator(case?.level ?: 0) + + } + } +} + +@SuppressLint("MissingPermission", "NewApi") +@Composable +fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice?, service: AirPodsService?) { + var deviceName by remember { mutableStateOf(TextFieldValue(device?.name ?: "AirPods Pro (fallback, should never show up)")) } Column( modifier = Modifier @@ -283,44 +436,26 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice? .padding(paddingValues) .padding(vertical = 24.dp, horizontal = 12.dp) ) { - Row { - Column ( - horizontalAlignment = Alignment.CenterHorizontally - ){ -// using this temporarily until i can find an image of only the buds - Image( - bitmap = ImageBitmap.imageResource(R.drawable.pro_2), - contentDescription = null, - modifier = Modifier.fillMaxWidth(0.5f) - ) - BatteryIndicator(batteryPercentage = 10) - } - Column ( - horizontalAlignment = Alignment.CenterHorizontally - ){ - Image( - bitmap = ImageBitmap.imageResource(R.drawable.pro_2), - contentDescription = null, - modifier = Modifier.fillMaxWidth() - ) - BatteryIndicator(batteryPercentage = 100) - } - } - StyledTextField( - name = "Name", - value = deviceName.text, - onValueChange = { deviceName = TextFieldValue(it) } - ) + BatteryView() + if (service != null) { + StyledTextField( + name = "Name", + value = deviceName.text, + onValueChange = { deviceName = TextFieldValue(it) } + ) - Spacer(modifier = Modifier.height(16.dp)) - NoiseControlSettings() - Spacer(modifier = Modifier.height(16.dp)) - AudioSettings() + Spacer(modifier = Modifier.height(16.dp)) + + NoiseControlSettings(service = service) + + Spacer(modifier = Modifier.height(16.dp)) + AudioSettings(service = service) + } } } @Composable -fun NoiseControlSlider() { +fun NoiseControlSlider(service: AirPodsService) { val sliderValue = remember { mutableStateOf(0f) } val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 @@ -338,7 +473,10 @@ fun NoiseControlSlider() { // Slider Slider( value = sliderValue.value, - onValueChange = { sliderValue.value = it }, + onValueChange = { + sliderValue.value = it + service.setAdaptiveStrength(it.toInt()) + }, valueRange = 0f..100f, steps = 99, modifier = Modifier @@ -378,7 +516,7 @@ fun NoiseControlSlider() { } @Composable -fun AudioSettings() { +fun AudioSettings(service: AirPodsService) { val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 val textColor = if (isDarkTheme) Color.White else Color.Black var conversationalAwarenessEnabled by remember { mutableStateOf(true) } @@ -392,7 +530,7 @@ fun AudioSettings() { ), modifier = Modifier.padding(8.dp, bottom = 2.dp) ) - val backgroundColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFFFFFFF) + val backgroundColor = if (isDarkTheme) Color(0xFF252525) else Color(0xFFFFFFFF) val isPressed = remember { mutableStateOf(false) } Column ( modifier = Modifier @@ -425,10 +563,14 @@ fun AudioSettings() { }, verticalAlignment = Alignment.CenterVertically ) { - Text(text = "Conversational Awareness", modifier = Modifier.weight(1f), fontSize = 16.sp) + Text(text = "Conversational Awareness", modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) + StyledSwitch( checked = conversationalAwarenessEnabled, - onCheckedChange = { conversationalAwarenessEnabled = it }, + onCheckedChange = { + conversationalAwarenessEnabled = it + service.setCAEnabled(it) + }, ) } Column ( @@ -457,15 +599,14 @@ fun AudioSettings() { color = textColor.copy(alpha = 0.6f) ) ) - NoiseControlSlider() + NoiseControlSlider(service = service) } } } @Composable -fun NoiseControlSettings() { +fun NoiseControlSettings(service: AirPodsService) { val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) val textColor = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black @@ -473,6 +614,19 @@ fun NoiseControlSettings() { val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } + val noiseControlReceiver = remember { + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1] + } + } + } + val context = LocalContext.current + LaunchedEffect(context) { + val noiseControlIntentFilter = IntentFilter(Notifications.ANC_DATA) + context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED) + } + // val paddingAnim by animateDpAsState( // targetValue = when (noiseControlMode.value) { // NoiseControlMode.OFF -> 0.dp @@ -488,6 +642,7 @@ fun NoiseControlSettings() { fun onModeSelected(mode: NoiseControlMode) { noiseControlMode.value = mode + service.setANCMode(mode.ordinal+1) when (mode) { NoiseControlMode.NOISE_CANCELLATION -> { d1a.value = 1f @@ -664,14 +819,11 @@ fun NoiseControlButton( } enum class NoiseControlMode { - OFF, TRANSPARENCY, ADAPTIVE, NOISE_CANCELLATION + OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE } -@Preview(showBackground = true, name = "AirPods Settings", - uiMode = Configuration.UI_MODE_NIGHT_YES, showSystemUi = true, - device = "spec:width=411dp,height=891dp" -) +@Preview @Composable fun PreviewAirPodsSettingsScreen() { - AirPodsSettingsScreen(PaddingValues(8.dp), null) + BatteryIndicator(100, true) } diff --git a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt new file mode 100644 index 0000000..a1f4978 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt @@ -0,0 +1,188 @@ +package me.kavishdevar.aln + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +enum class Enums(val value: ByteArray) { + NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION), + CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS), + CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY), + PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)), + SETTINGS(byteArrayOf(0x09, 0x00)), + SUFFIX(byteArrayOf(0x00, 0x00, 0x00)), + NOTIFICATION_FILTER(byteArrayOf(0x0f)), + HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)), + SPECIFIC_FEATURES(byteArrayOf(0x4d)), + SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00, + 0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)), + REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())), + NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value), + NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value), + NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value), + NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value), + NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value), + SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value), + SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value), + CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)); +} + +object BatteryComponent { + const val LEFT = 4 + const val RIGHT = 2 + const val CASE = 8 +} + +object BatteryStatus { + const val CHARGING = 1 + const val NOT_CHARGING = 2 + const val DISCONNECTED = 4 +} + +@Parcelize +data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable { + fun getComponentName(): String? { + return when (component) { + BatteryComponent.LEFT -> "LEFT" + BatteryComponent.RIGHT -> "RIGHT" + BatteryComponent.CASE -> "CASE" + else -> null + } + } + + fun getStatusName(): String? { + return when (status) { + BatteryStatus.CHARGING -> "CHARGING" + BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" + BatteryStatus.DISCONNECTED -> "DISCONNECTED" + else -> null + } + } +} + +class Notifications { + companion object { + const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED" + const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA" + const val EAR_DETECTION_DATA = "me.kavishdevar.aln.EAR_DETECTION_DATA" + 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" + } + + class EarDetection { + private val notificationBit = Capabilities.EAR_DETECTION + private val notificationPrefix = Enums.PREFIX.value + notificationBit + + var status: List = listOf(0x01, 0x01) + + fun setStatus(data: ByteArray) { + status = listOf(data[6], data[7]) + } + + fun isEarDetectionData(data: ByteArray): Boolean { + if (data.size != 8) { + return false + } + val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } + val dataHex = data.joinToString("") { "%02x".format(it) } + return dataHex.startsWith(prefixHex) + } + } + + class ANC { + private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value + + var status: Int = 1 + private set + + fun isANCData(data: ByteArray): Boolean { + if (data.size != 11) { + return false + } + val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) } + val dataHex = data.joinToString("") { "%02x".format(it) } + return dataHex.startsWith(prefixHex) + } + + fun setStatus(data: ByteArray) { + status = data[7].toInt() + } + + val name: String = + when (status) { + 1 -> "OFF" + 2 -> "ON" + 3 -> "TRANSPARENCY" + 4 -> "ADAPTIVE" + else -> "UNKNOWN" + } + + } + + class BatteryNotification { + private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED) + private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED) + private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) + + fun isBatteryData(data: ByteArray): Boolean { + if (data.size != 22) { + return false + } + return data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && + data[3] == 0x00.toByte() && data[4] == 0x04.toByte() && data[5] == 0x00.toByte() + } + + fun setBattery(data: ByteArray) { + first = Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) + second = Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) + case = Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) + } + + fun getBattery(): List { + val left = if (first.component == BatteryComponent.LEFT) first else second + val right = if (first.component == BatteryComponent.LEFT) second else first + return listOf(left, right, case) + } + } + + class ConversationalAwarenessNotification { + private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value + + var status: Byte = 0 + private set + + fun isConversationalAwarenessData(data: ByteArray): Boolean { + if (data.size != 10) { + return false + } + val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) } + val dataHex = data.joinToString("") { "%02x".format(it) } + return dataHex.startsWith(prefixHex) + } + + fun setData(data: ByteArray) { + status = data[9] + } + } +} + +class Capabilities { + companion object { + val NOISE_CANCELLATION = byteArrayOf(0x0d) + val CONVERSATION_AWARENESS = byteArrayOf(0x28) + val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02) + val EAR_DETECTION = byteArrayOf(0x06) + } + + enum class NoiseCancellation(val value: ByteArray) { + OFF(byteArrayOf(0x01)), + ON(byteArrayOf(0x02)), + TRANSPARENCY(byteArrayOf(0x03)), + ADAPTIVE(byteArrayOf(0x04)); + } + + enum class ConversationAwareness(val value: ByteArray) { + OFF(byteArrayOf(0x02)), + ON(byteArrayOf(0x01)); + } +} \ 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 3ce6f58..4bd54f7 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 @@ -1,6 +1,5 @@ package me.kavishdevar.aln.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme diff --git a/android/app/src/main/res/drawable/pro_2_buds.png b/android/app/src/main/res/drawable/pro_2_buds.png new file mode 100644 index 0000000..3516ef8 Binary files /dev/null and b/android/app/src/main/res/drawable/pro_2_buds.png differ diff --git a/android/app/src/main/res/drawable/pro_2_case.png b/android/app/src/main/res/drawable/pro_2_case.png new file mode 100644 index 0000000..f104605 Binary files /dev/null and b/android/app/src/main/res/drawable/pro_2_case.png differ diff --git a/android/app/src/main/res/font/sf_pro.ttf b/android/app/src/main/res/font/sf_pro.ttf new file mode 100755 index 0000000..1e8aa63 Binary files /dev/null and b/android/app/src/main/res/font/sf_pro.ttf differ diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 4694145..28d4fce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,5 +1,7 @@ [versions] +accompanistPermissions = "0.36.0" agp = "8.7.0-beta01" +hiddenapibypass = "4.3" kotlin = "2.0.0" coreKtx = "1.13.1" junit = "4.13.2" @@ -11,7 +13,9 @@ composeBom = "2024.04.01" annotations = "15.0" [libraries] +accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +hiddenapibypass = { module = "org.lsposed.hiddenapibypass:hiddenapibypass", version.ref = "hiddenapibypass" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }