diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index dd08573..0f052f7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.annotations) + implementation(libs.androidx.navigation.compose) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 52ec506..34bc80f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + android:enableOnBackInvokedCallback="true" + tools:targetApi="31" + tools:ignore="UnusedAttribute"> + + - + android:permission="android.permission.BLUETOOTH_CONNECT" /> \ 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 336296e..0a924e0 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt @@ -1,6 +1,9 @@ package me.kavishdevar.aln import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.Service import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothSocket @@ -14,12 +17,11 @@ import android.os.Build import android.os.IBinder import android.os.ParcelUuid import android.util.Log -import androidx.compose.runtime.mutableStateOf +import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.lsposed.hiddenapibypass.HiddenApiBypass -import kotlin.experimental.or class AirPodsService : Service() { inner class LocalBinder : Binder() { @@ -67,8 +69,61 @@ class AirPodsService : Service() { socket?.outputStream?.flush() } + val earDetectionNotification = AirPodsNotifications.EarDetection() + val ancNotification = AirPodsNotifications.ANC() + val batteryNotification = AirPodsNotifications.BatteryNotification() + val conversationAwarenessNotification = AirPodsNotifications.ConversationalAwarenessNotification() + + var earDetectionEnabled = true + + fun setCaseChargingSounds(enabled: Boolean) { + val bytes = byteArrayOf(0x12, 0x3a, 0x00, 0x01, 0x00, 0x08, if (enabled) 0x00 else 0x01) + socket?.outputStream?.write(bytes) + socket?.outputStream?.flush() + } + + fun setEarDetection(enabled: Boolean) { + earDetectionEnabled = enabled + } + + fun getBattery(): List { + return batteryNotification.getBattery() + } + + fun getANC(): Int { + return ancNotification.status + } + +// private fun buildBatteryText(battery: List): String { +// val left = battery[0] +// val right = battery[1] +// val case = battery[2] +// +// return "Left: ${left.level}% ${left.getStatusName()}, Right: ${right.level}% ${right.getStatusName()}, Case: ${case.level}% ${case.getStatusName()}" +// } + + private fun createNotification(): Notification { + val channelId = "battery" + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.pro_2_buds) + .setContentTitle("AirPods Connected") + .setOngoing(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + val channel = + NotificationChannel(channelId, "Battery Notification", NotificationManager.IMPORTANCE_LOW) + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + return notificationBuilder.build() + } + @SuppressLint("MissingPermission", "InlinedApi") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + + val notification = createNotification() + startForeground(1, notification) + if (isRunning) { return START_STICKY } @@ -85,22 +140,19 @@ class AirPodsService : Service() { 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)) + sendBroadcast(Intent(AirPodsNotifications.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 audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager + MediaController.initialize(audioManager) 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 { + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { putExtra("data", buffer.copyOfRange(0, bytesRead)) }) val bytes = buffer.copyOfRange(0, bytesRead) @@ -109,7 +161,7 @@ class AirPodsService : Service() { } if (earDetectionNotification.isEarDetectionData(data)) { earDetectionNotification.setStatus(data) - sendBroadcast(Intent(Notifications.EAR_DETECTION_DATA).apply { + sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { val list = earDetectionNotification.status val bytes = ByteArray(2) bytes[0] = list[0] @@ -117,44 +169,41 @@ class AirPodsService : Service() { putExtra("data", bytes) }) Log.d("AirPods Parser", "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}") - val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - val mediaController = MediaController(audioManager) var inEar = false val earReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val data = intent.getByteArrayExtra("data") - if (data != null) { + if (data != null && earDetectionEnabled) { inEar = if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) { data[0] == 0x00.toByte() || data[1] == 0x00.toByte() } else { data[0] == 0x00.toByte() && data[1] == 0x00.toByte() } - Log.d("AirPods Parser", "In Ear: $inEar") if (inEar) { - mediaController.sendPlay() + MediaController.sendPlay() } else { - mediaController.sendPause() + MediaController.sendPause() } } } } - val earIntentFilter = IntentFilter(Notifications.EAR_DETECTION_DATA) + val earIntentFilter = IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA) this@AirPodsService.registerReceiver(earReceiver, earIntentFilter, RECEIVER_EXPORTED ) } else if (ancNotification.isANCData(data)) { ancNotification.setStatus(data) - sendBroadcast(Intent(Notifications.ANC_DATA).apply { + sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { putExtra("data", ancNotification.status) }) Log.d("AirPods Parser", "ANC: ${ancNotification.status}") } else if (batteryNotification.isBatteryData(data)) { batteryNotification.setBattery(data) - sendBroadcast(Intent(Notifications.BATTERY_DATA).apply { + sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) }) for (battery in batteryNotification.getBattery()) { @@ -163,18 +212,14 @@ class AirPodsService : Service() { } else if (conversationAwarenessNotification.isConversationalAwarenessData(data)) { conversationAwarenessNotification.setData(data) - sendBroadcast(Intent(Notifications.CA_DATA).apply { + sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { putExtra("data", conversationAwarenessNotification.status) }) - if (conversationAwarenessNotification.status == 1.toByte() or 2.toByte()) { - val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - val mediaController = MediaController(audioManager) - mediaController.startSpeaking() + if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { + MediaController.startSpeaking() } - else if (conversationAwarenessNotification.status == 9.toByte() or 8.toByte()) { - val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager - val mediaController = MediaController(audioManager) - mediaController.stopSpeaking() + else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { + MediaController.stopSpeaking() } Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}") } diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt new file mode 100644 index 0000000..e98d8c2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsSettingsScreen.kt @@ -0,0 +1,894 @@ +package me.kavishdevar.aln + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +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.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.draw.shadow +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.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.navigation.NavController +import kotlin.math.roundToInt + +@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() + } + } + } + val context = LocalContext.current + + LaunchedEffect(context) { + val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) + } + } + + 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 { + if (left?.status != BatteryStatus.DISCONNECTED) { + 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)) + } + if (right?.status != BatteryStatus.DISCONNECTED) { + 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?, + navController: NavController) { + var deviceName by remember { mutableStateOf(TextFieldValue(device?.name ?: "AirPods Pro (fallback, should never show up)")) } + + val verticalScrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(vertical = 24.dp, horizontal = 12.dp) + .verticalScroll( + state = verticalScrollState, + enabled = true, + ) + ) { + LaunchedEffect(service) { + service?.let { + it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { + putParcelableArrayListExtra("data", ArrayList(it.getBattery())) + }) + it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { + putExtra("data", it.getANC()) + }) + } + } + BatteryView() + val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) + + if (service != null) { + StyledTextField( + name = "Name", + value = deviceName.text, + onValueChange = { deviceName = TextFieldValue(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)) + +// val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 +// val textColor = if (isDarkTheme) Color.White else Color.Black + + // localstorage stuff + // TODO: localstorage and call the setButtons() with previous configuration and new configuration +// Box ( +// modifier = Modifier +// .padding(vertical = 8.dp) +// .background( +// if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF), +// RoundedCornerShape(14.dp) +// ) +// ) +// { +// // TODO: A Column Rows with text at start and a check mark if ticked +// } + + Spacer(modifier = Modifier.height(16.dp)) + + Row ( + modifier = Modifier + .background(if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFFFFFFF), RoundedCornerShape(14.dp)) + .height(55.dp) + .clickable { + navController.navigate("debug") + } + ) { + Text(text = "Debug", modifier = Modifier.padding(16.dp), color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black) + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = { navController.navigate("debug") }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black ), + modifier = Modifier.padding(start = 16.dp).fillMaxHeight() + ) { + @Suppress("DEPRECATION") + Icon(imageVector = Icons.Default.KeyboardArrowRight, contentDescription = "Debug") + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoiseControlSlider(service: AirPodsService, sharedPreferences: SharedPreferences) { + val sliderValue = remember { mutableFloatStateOf(0f) } + LaunchedEffect(sliderValue) { + if (sharedPreferences.contains("adaptive_strength")) { + sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat() + } + } + LaunchedEffect(sliderValue.floatValue) { + sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply() + } + + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + + val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) + val activeTrackColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFF007AFF) + val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) + val labelTextColor = if (isDarkTheme) Color.White else Color.Black + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Slider + Slider( + value = sliderValue.floatValue, + onValueChange = { + sliderValue.floatValue = it + service.setAdaptiveStrength(100 - it.toInt()) + }, + valueRange = 0f..100f, + onValueChangeFinished = { + // Round the value when the user stops sliding + sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() + }, + modifier = Modifier + .fillMaxWidth() + .height(36.dp), // Adjust height to ensure thumb fits well + colors = SliderDefaults.colors( + thumbColor = thumbColor, + activeTrackColor = activeTrackColor, + inactiveTrackColor = trackColor + ), + thumb = { + Box( + modifier = Modifier + .size(24.dp) // Circular thumb size + .shadow(4.dp, CircleShape) // Apply shadow to the thumb + .background(thumbColor, CircleShape) // Circular thumb + ) + }, + track = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + .background(trackColor, RoundedCornerShape(6.dp)) + ) + } + ) + + // Labels + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Less Noise", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = labelTextColor + ), + modifier = Modifier.padding(start = 4.dp) + ) + Text( + text = "More Noise", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = labelTextColor + ), + modifier = Modifier.padding(end = 4.dp) + ) + } + } +} + +@Preview +@Composable +fun Preview() { + IndependentToggle("Case Charging Sounds", AirPodsService(), "setCaseChargingSounds", LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)) +} + +@Composable +fun IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + val textColor = if (isDarkTheme) Color.White else Color.Black + + // Standardize the key + val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase() + + // State for the toggle + var checked by remember { mutableStateOf(default) } + + // Load initial state from SharedPreferences + LaunchedEffect(sharedPreferences) { + checked = sharedPreferences.getBoolean(snakeCasedName, true) + } + Box ( + modifier = Modifier + .padding(vertical = 8.dp) + .background( + if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF), + RoundedCornerShape(14.dp) + ) + ) + { + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 12.dp) + .clickable { + // Toggle checked state and save to SharedPreferences + checked = !checked + sharedPreferences + .edit() + .putBoolean(snakeCasedName, checked) + .apply() + + // Call the corresponding method in the service + val method = service::class.java.getMethod(functionName, Boolean::class.java) + method.invoke(service, checked) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) + StyledSwitch( + checked = checked, + onCheckedChange = { + checked = it + sharedPreferences.edit().putBoolean(snakeCasedName, it).apply() + + // Call the corresponding method in the service + val method = service::class.java.getMethod(functionName, Boolean::class.java) + method.invoke(service, it) + }, + ) + } + } +} + +@Composable +fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + val textColor = if (isDarkTheme) Color.White else Color.Black + + // Load the conversational awareness state from sharedPreferences + var conversationalAwarenessEnabled by remember { + mutableStateOf( + sharedPreferences.getBoolean("conversational_awareness", true) + ) + } + + // Update the service when the toggle is changed + fun updateConversationalAwareness(enabled: Boolean) { + conversationalAwarenessEnabled = enabled + sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply() + service.setCAEnabled(enabled) + } + + Text( + text = "AUDIO", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(8.dp, bottom = 2.dp) + ) + + val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFFFFFFF) + val isPressed = remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + .padding(top = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + shape = RoundedCornerShape(14.dp), + color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent + ) + .padding(horizontal = 12.dp, vertical = 12.dp) + .pointerInput(Unit) { // Detect press state for iOS-like effect + detectTapGestures( + onPress = { + isPressed.value = true + tryAwaitRelease() // Wait until release + isPressed.value = false + } + ) + } + .clickable( + indication = null, // Disable ripple effect + interactionSource = remember { MutableInteractionSource() } // Required for clickable + ) { + // Toggle the conversational awareness value + updateConversationalAwareness(!conversationalAwarenessEnabled) + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Conversational Awareness", + modifier = Modifier.weight(1f), + fontSize = 16.sp, + color = textColor + ) + + StyledSwitch( + checked = conversationalAwarenessEnabled, + onCheckedChange = { + updateConversationalAwareness(it) + }, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 10.dp) + ) { + Text( + text = "Adaptive Audio", + modifier = Modifier + .padding(end = 8.dp, bottom = 2.dp, start = 2.dp) + .fillMaxWidth(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + ) + Text( + text = "Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.", + modifier = Modifier + .padding(8.dp, top = 2.dp) + .fillMaxWidth(), + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(alpha = 0.6f) + ) + ) + + NoiseControlSlider(service = service, sharedPreferences = sharedPreferences) + } + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun NoiseControlSettings(service: AirPodsService) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + val backgroundColor = if (isDarkTheme) Color(0xFF1C1B20) else Color(0xFFE3E3E8) + val textColor = if (isDarkTheme) Color.White else Color.Black + val textColorSelected = if (isDarkTheme) Color.White else Color.Black + val selectedBackground = if (isDarkTheme) Color(0xFF5C5A5F) else Color(0xFFFFFFFF) + + 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 + val noiseControlIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA) + context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED) + +// val paddingAnim by animateDpAsState( +// targetValue = when (noiseControlMode.value) { +// NoiseControlMode.OFF -> 0.dp +// NoiseControlMode.TRANSPARENCY -> 150.dp +// NoiseControlMode.ADAPTIVE -> 250.dp +// NoiseControlMode.NOISE_CANCELLATION -> 350.dp +// }, label = "" +// ) + + val d1a = remember { mutableFloatStateOf(0f) } + val d2a = remember { mutableFloatStateOf(0f) } + val d3a = remember { mutableFloatStateOf(0f) } + + fun onModeSelected(mode: NoiseControlMode) { + noiseControlMode.value = mode + service.setANCMode(mode.ordinal+1) + when (mode) { + NoiseControlMode.NOISE_CANCELLATION -> { + d1a.floatValue = 1f + d2a.floatValue = 1f + d3a.floatValue = 0f + } + NoiseControlMode.OFF -> { + d1a.floatValue = 0f + d2a.floatValue = 1f + d3a.floatValue = 1f + } + NoiseControlMode.ADAPTIVE -> { + d1a.floatValue = 1f + d2a.floatValue = 0f + d3a.floatValue = 0f + } + NoiseControlMode.TRANSPARENCY -> { + d1a.floatValue = 0f + d2a.floatValue = 0f + d3a.floatValue = 1f + } + } + } + + Text( + text = "NOISE CONTROL", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f) + ), + modifier = Modifier.padding(8.dp, bottom = 2.dp) + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(75.dp) + .padding(8.dp) + ) { +// Box( +// modifier = Modifier +// .fillMaxHeight() +// .width(80.dp) +// .offset(x = paddingAnim) +// .background(selectedBackground, RoundedCornerShape(8.dp)) +// ) + Row( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(14.dp)) + ) { + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.OFF) }, + textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, + backgroundColor = if (noiseControlMode.value == NoiseControlMode.OFF) selectedBackground else Color.Transparent, + modifier = Modifier.weight(1f) + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d1a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.transparency), + onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, + textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, + backgroundColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) selectedBackground else Color.Transparent, + modifier = Modifier.weight(1f) + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d2a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.adaptive), + onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, + textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, + backgroundColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) selectedBackground else Color.Transparent, + modifier = Modifier.weight(1f) + ) + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(d3a.floatValue), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ) + NoiseControlButton( + icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), + onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, + textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, + backgroundColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) selectedBackground else Color.Transparent, + modifier = Modifier.weight(1f) + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(top = 1.dp) + ) { + Text( + text = "Off", + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = "Transparency", + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = "Adaptive", + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = "Noise Cancellation", + style = TextStyle(fontSize = 12.sp, color = textColor), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +fun NoiseControlButton( + icon: ImageBitmap, + onClick: () -> Unit, + textColor: Color, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 4.dp) + .background(color = backgroundColor, shape = RoundedCornerShape(11.dp)) + .clickable( + onClick = onClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + bitmap = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(40.dp) + ) + } +} + +enum class NoiseControlMode { + OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE +} + +@Composable +fun StyledSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 + + val thumbColor = Color.White + val trackColor = if (checked) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) + + // Animate the horizontal offset of the thumb + val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test") + + Box( + modifier = Modifier + .width(51.dp) + .height(31.dp) + .clip(RoundedCornerShape(15.dp)) + .background(trackColor) // Dynamic track background + .padding(horizontal = 3.dp), + contentAlignment = Alignment.CenterStart + ) { + Box( + modifier = Modifier + .offset(x = thumbOffsetX) // Animate the offset for smooth transition + .size(27.dp) + .clip(CircleShape) + .background(thumbColor) // Dynamic thumb color + .clickable { onCheckedChange(!checked) } // Make the switch clickable + ) + } +} + +@Composable +fun StyledTextField( + name: String, + value: String, + onValueChange: (String) -> Unit +) { + 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 + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .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 + ) + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle( + color = textColor, // Dynamic text color + fontSize = 16.sp, + ), + cursorBrush = SolidColor(cursorColor), // Dynamic cursor color + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + innerTextField() + } + }, + modifier = Modifier + .fillMaxWidth() // Ensures text field takes remaining available space + .padding(start = 8.dp), // Padding to adjust spacing between text field and icon, + ) + } +} + +@Composable +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 + + // Battery indicator dimensions + val batteryWidth = 30.dp + val batteryHeight = 15.dp + val batteryCornerRadius = 4.dp + val tipWidth = 5.dp + val tipHeight = batteryHeight * 0.3f + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Row for battery icon and tip + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.padding(bottom = 4.dp) // Padding between icon and percentage text + ) { + // Battery Icon + Box( + modifier = Modifier + .width(batteryWidth) + .height(batteryHeight) + .border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius)) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .padding(2.dp) + .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) + Box( + modifier = Modifier + .width(tipWidth) + .height(tipHeight) + .padding(start = 1.dp) + .background( + batteryOutlineColor, + RoundedCornerShape( + topStart = 0.dp, + topEnd = 12.dp, + bottomStart = 0.dp, + bottomEnd = 12.dp + ) + ) + ) + } + + // Battery Percentage Text + Text( + text = "$batteryPercentage%", + color = batteryTextColor, + style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) + ) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt new file mode 100644 index 0000000..4bd4995 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/DebugScreen.kt @@ -0,0 +1,225 @@ +package me.kavishdevar.aln + +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.IBinder +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun DebugScreen(navController: NavController) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Debug") }, + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) + } + } + ) + }, + containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000) + else Color(0xFFF2F2F7), + ) { paddingValues -> + + val text = remember { mutableStateListOf("Log Start") } + val context = LocalContext.current + val listState = rememberLazyListState() + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val data = intent.getByteArrayExtra("data") + data?.let { + text.add(">" + it.joinToString(" ") { byte -> "%02X".format(byte) }) // Use ">" for received packets + } + } + } + + LaunchedEffect(context) { + val intentFilter = IntentFilter(AirPodsNotifications.AIRPODS_DATA) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED) + } + } + + LaunchedEffect(text.size) { + if (text.isNotEmpty()) { + listState.animateScrollToItem(text.size - 1) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding(), // Ensures padding for keyboard visibility + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + content = { + items(text.size) { index -> + val message = text[index] + val isSent = message.startsWith(">") + val backgroundColor = if (isSent) Color(0xFFE1FFC7) else Color(0xFFD1D1D1) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .background(backgroundColor, RoundedCornerShape(12.dp)) + .padding(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (!isSent) { + Text("<", color = Color(0xFF00796B), fontSize = 16.sp) + } + + Text( + text = if (isSent) message.substring(1) else message, // Remove the ">" from sent packets + fontFamily = FontFamily(Font(R.font.hack)), + color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000) + else Color(0xFF000000), + modifier = Modifier.weight(1f) // Allows text to take available space + ) + + if (isSent) { + Text(">", color = Color(0xFF00796B), fontSize = 16.sp) + } + } + } + } + } + ) + 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) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF1C1B20)), + verticalAlignment = Alignment.CenterVertically + ) { + val packet = remember { mutableStateOf(TextFieldValue("")) } + TextField( + value = packet.value, + onValueChange = { packet.value = it }, + label = { Text("Packet") }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), // Padding for the input field + trailingIcon = { + IconButton( + onClick = { + airPodsService.value?.sendPacket(packet.value.text) + text.add(packet.value.text) // Add sent message directly without prefix + packet.value = TextFieldValue("") // Clear input field after sending + } + ) { + @Suppress("DEPRECATION") + Icon(Icons.Filled.Send, contentDescription = "Send") + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7), + unfocusedContainerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF1C1B20) else Color(0xFFF2F2F7), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedTextColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black, + unfocusedTextColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black.copy(alpha = 0.6f), + focusedLabelColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White.copy(alpha = 0.6f) else Color.Black, + unfocusedLabelColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), + ), + shape = RoundedCornerShape(12.dp) + ) + + 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) + } + } + } +} \ 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 b96992a..fd3e609 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -4,14 +4,10 @@ import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothProfile -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.ServiceConnection -import android.media.AudioManager -import android.os.Build import android.os.Bundle import android.os.IBinder import android.os.ParcelUuid @@ -19,79 +15,40 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -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.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.TopAppBarDefaults 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 -import androidx.compose.runtime.setValue -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.draw.shadow 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.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.getSystemService +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController 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 -import kotlin.math.roundToInt +@ExperimentalMaterial3Api class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -100,7 +57,28 @@ class MainActivity : ComponentActivity() { setContent { ALNTheme { Scaffold ( - containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color(0xFF000000) else Color(0xFFFFFFFF) + containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color( + 0xFF000000 + ) else Color( + 0xFFF2F2F7 + ), + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = "AirPods Pro Settings", + color = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color.White else Color.Black, + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = if (MaterialTheme.colorScheme.surface.luminance() < 0.5) Color( + 0xFF000000 + ) else Color( + 0xFFF2F2F7 + ), + ) + ) + } ) { innerPadding -> Main(innerPadding) } @@ -109,172 +87,6 @@ class MainActivity : ComponentActivity() { } } -@SuppressLint("UseOfNonLambdaOffsetOverload") -@Composable -fun StyledSwitch( - checked: Boolean, - onCheckedChange: (Boolean) -> Unit -) { - val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 - - val thumbColor = Color.White - val trackColor = if (checked) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF262629) else Color(0xFFD1D1D6) - - // Animate the horizontal offset of the thumb - val thumbOffsetX by animateDpAsState(targetValue = if (checked) 20.dp else 0.dp, label = "Test") - - Box( - modifier = Modifier - .width(51.dp) - .height(31.dp) - .clip(RoundedCornerShape(15.dp)) - .background(trackColor) // Dynamic track background - .padding(horizontal = 3.dp), - contentAlignment = Alignment.CenterStart - ) { - Box( - modifier = Modifier - .offset(x = thumbOffsetX) // Animate the offset for smooth transition - .size(27.dp) - .clip(CircleShape) - .background(thumbColor) // Dynamic thumb color - .clickable { onCheckedChange(!checked) } // Make the switch clickable - ) - } -} - -@Composable -fun StyledTextField( - name: String, - value: String, - onValueChange: (String) -> Unit -) { - val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 - - 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 - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(10.dp) - ) // Dynamic background based on theme - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Text( - text = name, - style = TextStyle( - fontSize = 16.sp, - color = textColor // Text color based on theme - ) - ) - - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = TextStyle( - color = textColor, // Dynamic text color - fontSize = 16.sp, - ), - cursorBrush = SolidColor(cursorColor), // Dynamic cursor color - decorationBox = { innerTextField -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - innerTextField() - } - }, - modifier = Modifier - .fillMaxWidth() // Ensures text field takes remaining available space - .padding(start = 8.dp) // Padding to adjust spacing between text field and icon - ) - } -} - -@Composable -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 - - // Battery indicator dimensions - val batteryWidth = 30.dp - val batteryHeight = 15.dp - val batteryCornerRadius = 4.dp - val tipWidth = 5.dp - val tipHeight = batteryHeight * 0.3f - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - // Row for battery icon and tip - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(0.dp), - modifier = Modifier.padding(bottom = 4.dp) // Padding between icon and percentage text - ) { - // Battery Icon - Box( - modifier = Modifier - .width(batteryWidth) - .height(batteryHeight) - .border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius)) - ) { - Box( - modifier = Modifier - .fillMaxHeight() - .padding(2.dp) - .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) - Box( - modifier = Modifier - .width(tipWidth) - .height(tipHeight) - .padding(start = 1.dp) - .background( - batteryOutlineColor, - RoundedCornerShape( - topStart = 0.dp, - topEnd = 12.dp, - bottomStart = 0.dp, - bottomEnd = 12.dp - ) - ) - ) - } - - // Battery Percentage Text - Text( - text = "$batteryPercentage%", - color = batteryTextColor, - style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) - ) - } -} - @SuppressLint("MissingPermission") @OptIn(ExperimentalPermissionsApi::class) @Composable @@ -290,6 +102,9 @@ fun Main(paddingValues: PaddingValues) { val bluetoothAdapter = bluetoothManager?.adapter val devices = bluetoothAdapter?.bondedDevices val airpodsDevice = remember { mutableStateOf(null) } + + val navController = rememberNavController() + if (devices != null) { for (device in devices) { if (device.uuids.contains(uuid)) { @@ -328,16 +143,38 @@ fun Main(paddingValues: PaddingValues) { 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 - ) + 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 + ){ + composable("notConnected") { + Text("Not Connected...") + } + composable("settings") { + AirPodsSettingsScreen( + paddingValues, + airpodsDevice.value, + service = airPodsService.value, + navController = navController + ) + } + composable("debug") { + DebugScreen(navController = navController) + } + } + if (airpodsDevice.value != null) { + LaunchedEffect(Unit) { + navController.navigate("settings") { + popUpTo("notConnected") { inclusive = true } + } + } } else { Text("No AirPods connected") @@ -363,519 +200,8 @@ fun Main(paddingValues: PaddingValues) { } } -@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() - } - } - } - val context = LocalContext.current - - LaunchedEffect(context) { - val batteryIntentFilter = IntentFilter(Notifications.BATTERY_DATA) - context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) - } - - 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 { - if (left?.status != BatteryStatus.DISCONNECTED) { - 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)) - } - if (right?.status != BatteryStatus.DISCONNECTED) { - 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 - .fillMaxSize() - .padding(paddingValues) - .padding(vertical = 24.dp, horizontal = 12.dp) - ) { - BatteryView() - if (service != null) { - StyledTextField( - name = "Name", - value = deviceName.text, - onValueChange = { deviceName = TextFieldValue(it) } - ) - - Spacer(modifier = Modifier.height(16.dp)) - - NoiseControlSettings(service = service) - - Spacer(modifier = Modifier.height(16.dp)) - AudioSettings(service = service) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoiseControlSlider(service: AirPodsService) { - val sliderValue = remember { mutableStateOf(0f) } - val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5 - - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) - val activeTrackColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFF007AFF) - val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFF007AFF) - val labelTextColor = if (isDarkTheme) Color.White else Color.Black - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Slider - Slider( - value = sliderValue.value, - onValueChange = { - sliderValue.value = it - service.setAdaptiveStrength(100 - it.toInt()) - }, - valueRange = 0f..100f, - onValueChangeFinished = { - // Round the value when the user stops sliding - sliderValue.value = sliderValue.value.roundToInt().toFloat() - }, - modifier = Modifier - .fillMaxWidth() - .height(36.dp), // Adjust height to ensure thumb fits well - colors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = trackColor - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) // Circular thumb size - .shadow(4.dp, CircleShape) // Apply shadow to the thumb - .background(thumbColor, CircleShape) // Circular thumb - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp) - .background(trackColor, RoundedCornerShape(6.dp)) - ) - } - ) - - // Labels - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Less Noise", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(start = 4.dp) - ) - Text( - text = "More Noise", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = labelTextColor - ), - modifier = Modifier.padding(end = 4.dp) - ) - } - } -} - -@Composable -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) } - - Text( - text = "AUDIO", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) - val backgroundColor = if (isDarkTheme) Color(0xFF252525) else Color(0xFFFFFFFF) - val isPressed = remember { mutableStateOf(false) } - Column ( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(12.dp)) - .padding(top = 2.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(12.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { // Detect press state for iOS-like effect - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() // Wait until release - isPressed.value = false - } - ) - } - .clickable( - indication = null, // Disable ripple effect - interactionSource = remember { MutableInteractionSource() } // Required for clickable - ) { - conversationalAwarenessEnabled = !conversationalAwarenessEnabled - }, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Conversational Awareness", modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor) - - StyledSwitch( - checked = conversationalAwarenessEnabled, - onCheckedChange = { - conversationalAwarenessEnabled = it - service.setCAEnabled(it) - }, - ) - } - Column ( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 10.dp) - ) { - Text( - text = "Adaptive Audio", - modifier = Modifier - .padding(end = 8.dp, bottom = 2.dp, start = 2.dp) - .fillMaxWidth(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) - ) - Text( - text = "Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise", - modifier = Modifier - .padding(8.dp, top = 2.dp) - .fillMaxWidth(), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(alpha = 0.6f) - ) - ) - - NoiseControlSlider(service = service) - val packet = remember { mutableStateOf ("") } - - Row ( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - TextField( - value = packet.value, - onValueChange = { packet.value = it }, - modifier = Modifier.fillMaxWidth(0.75f), - ) - Button(onClick = { - service.sendPacket(packet.value) - }, - modifier = Modifier - .padding(start = 8.dp) - .fillMaxWidth() - ) { - Text(text = "Send") - } - } - } - } -} - -@Composable -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 - val selectedBackground = if (isDarkTheme) Color(0xFF090909) else Color(0xFFFFFFFF) - - 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 -// NoiseControlMode.TRANSPARENCY -> 150.dp -// NoiseControlMode.ADAPTIVE -> 250.dp -// NoiseControlMode.NOISE_CANCELLATION -> 350.dp -// }, label = "" -// ) - - val d1a = remember { mutableStateOf(0f) } - val d2a = remember { mutableStateOf(0f) } - val d3a = remember { mutableStateOf(0f) } - - fun onModeSelected(mode: NoiseControlMode) { - noiseControlMode.value = mode - service.setANCMode(mode.ordinal+1) - when (mode) { - NoiseControlMode.NOISE_CANCELLATION -> { - d1a.value = 1f - d2a.value = 1f - d3a.value = 0f - } - NoiseControlMode.OFF -> { - d1a.value = 0f - d2a.value = 1f - d3a.value = 1f - } - NoiseControlMode.ADAPTIVE -> { - d1a.value = 1f - d2a.value = 0f - d3a.value = 0f - } - NoiseControlMode.TRANSPARENCY -> { - d1a.value = 0f - d2a.value = 0f - d3a.value = 1f - } - } - } - - Text( - text = "NOISE CONTROL", - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - color = textColor.copy(alpha = 0.6f) - ), - modifier = Modifier.padding(8.dp, bottom = 2.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(65.dp) - .padding(8.dp) - ) { -// Box( -// modifier = Modifier -// .fillMaxHeight() -// .width(80.dp) -// .offset(x = paddingAnim) -// .background(selectedBackground, RoundedCornerShape(8.dp)) -// ) - Row( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(8.dp)) - ) { - NoiseControlButton( - icon = Icons.Default.Person, // Replace with your icon - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.OFF) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.value), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - ) - NoiseControlButton( - icon = Icons.Default.Person, // Replace with your icon - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.value), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - ) - NoiseControlButton( - icon = Icons.Default.Person, // Replace with your icon - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.value), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - ) - NoiseControlButton( - icon = Icons.Default.Person, // Replace with your icon - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - backgroundColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) selectedBackground else Color.Transparent, - modifier = Modifier.weight(1f) - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(top = 1.dp) - ) { - Text( - text = "Off", - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - Text( - text = "Transparency", - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - Text( - text = "Adaptive", - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - Text( - text = "Noise Cancellation", - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -fun NoiseControlButton( - icon: ImageVector, - onClick: () -> Unit, - textColor: Color, - backgroundColor: Color, - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .fillMaxHeight() - .padding(horizontal = 4.dp, vertical = 4.dp) - .background(color = backgroundColor, shape = RoundedCornerShape(6.dp)) - .clickable( - onClick = onClick, - indication = null, - interactionSource = remember { MutableInteractionSource() }), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = textColor, - modifier = Modifier.size(32.dp) - ) - } -} - -enum class NoiseControlMode { - OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE -} - -@Preview +@PreviewLightDark @Composable fun PreviewAirPodsSettingsScreen() { - BatteryIndicator(100, true) + AirPodsSettingsScreen(paddingValues = PaddingValues(0.dp), device = null, service = null, navController = rememberNavController()) } diff --git a/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt b/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt index 3d5e53b..c0e0064 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt @@ -1,9 +1,19 @@ package me.kavishdevar.aln import android.media.AudioManager +import android.util.Log import android.view.KeyEvent -class MediaController (private val audioManager: AudioManager){ +object MediaController { + private var initialVolume: Int? = null // Nullable to track the unset state + private lateinit var audioManager: AudioManager // Declare AudioManager + + // Initialize the singleton with the AudioManager instance + fun initialize(audioManager: AudioManager) { + this.audioManager = audioManager + } + + @Synchronized fun sendPause() { if (audioManager.isMusicActive) { audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)) @@ -11,26 +21,35 @@ class MediaController (private val audioManager: AudioManager){ } } + @Synchronized fun sendPlay() { if (!audioManager.isMusicActive) { audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY)) audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY)) } } - var initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + + @Synchronized fun startSpeaking() { - if (!audioManager.isMusicActive) { - // reduce volume to 10% of initial volume + Log.d("MediaController", "Starting speaking") + if (initialVolume == null) { initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (initialVolume * 0.1).toInt(), 0) + Log.d("MediaController", "Initial Volume Set: $initialVolume") + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + 1, // Set to a lower volume when speaking starts + 0 + ) } + Log.d("MediaController", "Initial Volume: $initialVolume") } + @Synchronized fun stopSpeaking() { - if (!audioManager.isMusicActive) { - // restore initial volume - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, initialVolume, 0) + Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume") + initialVolume?.let { volume -> + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0) + initialVolume = null // Reset to null after restoring the volume } - } } \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt index b6ba1a4..62e7e62 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt @@ -59,7 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel } } -class Notifications { +class AirPodsNotifications { companion object { const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED" const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA" diff --git a/android/app/src/main/res/drawable/adaptive.png b/android/app/src/main/res/drawable/adaptive.png new file mode 100644 index 0000000..d3ada82 Binary files /dev/null and b/android/app/src/main/res/drawable/adaptive.png differ diff --git a/android/app/src/main/res/drawable/noise_cancellation.png b/android/app/src/main/res/drawable/noise_cancellation.png new file mode 100644 index 0000000..e80e71d Binary files /dev/null and b/android/app/src/main/res/drawable/noise_cancellation.png differ diff --git a/android/app/src/main/res/drawable/transparency.png b/android/app/src/main/res/drawable/transparency.png new file mode 100644 index 0000000..aae1de8 Binary files /dev/null and b/android/app/src/main/res/drawable/transparency.png differ diff --git a/android/app/src/main/res/font/hack.ttf b/android/app/src/main/res/font/hack.ttf new file mode 100644 index 0000000..92a90cb Binary files /dev/null and b/android/app/src/main/res/font/hack.ttf differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 97bb264..03f6736 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ ALN + DebugActivity \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c47be30..0059329 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -11,6 +11,7 @@ lifecycleRuntimeKtx = "2.8.6" activityCompose = "1.9.2" composeBom = "2024.09.03" annotations = "26.0.0" +navigationCompose = "2.8.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -30,6 +31,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }