implement music play/pause from ear detection

This commit is contained in:
Kavish Devar
2024-10-11 13:24:05 +05:30
parent 0487ea1f69
commit 81d07a7795
9 changed files with 196 additions and 20 deletions

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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