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