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