prepare for first release?

This commit is contained in:
Kavish Devar
2024-10-14 02:49:44 +05:30
parent 81d07a7795
commit bd73b81f50
14 changed files with 1301 additions and 782 deletions

View File

@@ -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)

View File

@@ -3,6 +3,9 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -13,7 +16,10 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ALN"
tools:targetApi="31">
android:enableOnBackInvokedCallback="true"
tools:targetApi="31"
tools:ignore="UnusedAttribute">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -23,13 +29,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".AirPodsService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="connectedDevice"
android:permission="android.permission.BLUETOOTH_CONNECT">
</service>
android:permission="android.permission.BLUETOOTH_CONNECT" />
</application>
</manifest>

View File

@@ -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<Battery> {
return batteryNotification.getBattery()
}
fun getANC(): Int {
return ancNotification.status
}
// private fun buildBatteryText(battery: List<Battery>): 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}")
}

View File

@@ -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<List<Battery>>(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)
)
}
}

View File

@@ -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<String>("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<AirPodsService?>(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<AirPodsService?>(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)
}
}
}
}

View File

@@ -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<BluetoothDevice?>(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<List<Battery>>(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())
}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name">ALN</string>
<string name="title_activity_debug">DebugActivity</string>
</resources>

View File

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