diff --git a/android/app/release/baselineProfiles/0/app-release.dm b/android/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..7d4a3cc Binary files /dev/null and b/android/app/release/baselineProfiles/0/app-release.dm differ diff --git a/android/app/release/baselineProfiles/1/app-release.dm b/android/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..9a2b1b2 Binary files /dev/null and b/android/app/release/baselineProfiles/1/app-release.dm differ diff --git a/android/app/release/output-metadata.json b/android/app/release/output-metadata.json new file mode 100644 index 0000000..490f98c --- /dev/null +++ b/android/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "me.kavishdevar.aln", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 28 +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt index bc94c8a..336296e 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/AirPodsService.kt @@ -4,16 +4,22 @@ import android.annotation.SuppressLint import android.app.Service import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothSocket +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.media.AudioManager import android.os.Binder import android.os.Build import android.os.IBinder import android.os.ParcelUuid import android.util.Log +import androidx.compose.runtime.mutableStateOf 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() { @@ -27,6 +33,12 @@ class AirPodsService : Service() { var isRunning: Boolean = false private var socket: BluetoothSocket? = null + fun sendPacket(packet: String) { + val fromHex = packet.split(" ").map { it.toInt(16).toByte() } + socket?.outputStream?.write(fromHex.toByteArray()) + socket?.outputStream?.flush() + } + fun setANCMode(mode: Int) { when (mode) { 1 -> { @@ -50,14 +62,12 @@ class AirPodsService : Service() { } 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") + val bytes = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2E, strength.toByte(), 0x00, 0x00, 0x00) socket?.outputStream?.write(bytes) socket?.outputStream?.flush() } - @SuppressLint("MissingPermission") + @SuppressLint("MissingPermission", "InlinedApi") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (isRunning) { return START_STICKY @@ -107,6 +117,33 @@ 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) { + 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() + } + else { + mediaController.sendPause() + } + } + } + } + + val earIntentFilter = IntentFilter(Notifications.EAR_DETECTION_DATA) + this@AirPodsService.registerReceiver(earReceiver, earIntentFilter, + RECEIVER_EXPORTED + ) } else if (ancNotification.isANCData(data)) { ancNotification.setStatus(data) @@ -129,6 +166,16 @@ class AirPodsService : Service() { sendBroadcast(Intent(Notifications.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() + } + else if (conversationAwarenessNotification.status == 9.toByte() or 8.toByte()) { + val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager + val mediaController = MediaController(audioManager) + mediaController.stopSpeaking() + } Log.d("AirPods Parser", "Conversation Awareness: ${conversationAwarenessNotification.status}") } else { } 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 7d3d2e9..b96992a 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/MainActivity.kt @@ -10,6 +10,7 @@ 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 @@ -45,12 +46,14 @@ 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.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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -63,6 +66,7 @@ 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 @@ -86,6 +90,7 @@ 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 class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -397,11 +402,15 @@ fun BatteryView() { } 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) + 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) + } } } } @@ -420,7 +429,6 @@ fun BatteryView() { .fillMaxWidth() ) BatteryIndicator(case?.level ?: 0) - } } } @@ -454,6 +462,7 @@ fun AirPodsSettingsScreen(paddingValues: PaddingValues, device: BluetoothDevice? } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun NoiseControlSlider(service: AirPodsService) { val sliderValue = remember { mutableStateOf(0f) } @@ -475,17 +484,37 @@ fun NoiseControlSlider(service: AirPodsService) { value = sliderValue.value, onValueChange = { sliderValue.value = it - service.setAdaptiveStrength(it.toInt()) + service.setAdaptiveStrength(100 - it.toInt()) }, valueRange = 0f..100f, - steps = 3, + onValueChangeFinished = { + // Round the value when the user stops sliding + sliderValue.value = sliderValue.value.roundToInt().toFloat() + }, modifier = Modifier - .fillMaxWidth(), + .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 @@ -599,7 +628,30 @@ fun AudioSettings(service: AirPodsService) { 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") + } + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt b/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt new file mode 100644 index 0000000..3d5e53b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/aln/MediaController.kt @@ -0,0 +1,36 @@ +package me.kavishdevar.aln + +import android.media.AudioManager +import android.view.KeyEvent + +class MediaController (private val audioManager: AudioManager){ + fun sendPause() { + if (audioManager.isMusicActive) { + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PAUSE)) + audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PAUSE)) + } + } + + 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) + fun startSpeaking() { + if (!audioManager.isMusicActive) { + // reduce volume to 10% of initial volume + initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (initialVolume * 0.1).toInt(), 0) + } + } + + fun stopSpeaking() { + if (!audioManager.isMusicActive) { + // restore initial volume + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, initialVolume, 0) + } + + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt index a1f4978..b6ba1a4 100644 --- a/android/app/src/main/java/me/kavishdevar/aln/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/aln/Packets.kt @@ -135,7 +135,11 @@ class Notifications { 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()) + case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { + Battery(data[17].toInt(), case.level, data[20].toInt()) + } else { + Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) + } } fun getBattery(): List { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 1efa466..c47be30 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] accompanistPermissions = "0.36.0" -agp = "8.7.0-rc01" +agp = "8.7.0" hiddenapibypass = "4.3" kotlin = "2.0.0" coreKtx = "1.13.1" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" -lifecycleRuntimeKtx = "2.8.5" +lifecycleRuntimeKtx = "2.8.6" activityCompose = "1.9.2" -composeBom = "2024.04.01" -annotations = "15.0" +composeBom = "2024.09.03" +annotations = "26.0.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e901423..b4dd5bf 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Oct 07 22:30:36 IST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists