enable noise control functionality, and battery

This commit is contained in:
Kavish Devar
2024-10-09 14:06:35 +05:30
parent 05c17a0377
commit a296117ec5
10 changed files with 601 additions and 97 deletions

View File

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

View File

@@ -20,10 +20,16 @@
android:theme="@style/Theme.ALN">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<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>
</application>
</manifest>

View File

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

View File

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

View File

@@ -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<Byte> = 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<Battery> {
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));
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

View File

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