/* LibrePods - AirPods liberated from Apple’s ecosystem Copyright (C) 2025 LibrePods contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ @file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods import android.annotation.SuppressLint 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.util.Log import android.view.Gravity import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.animation.Crossfade import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectVerticalDragGestures 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.presentation.components.IconAreaSize import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.NoiseControlMode import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs class QuickSettingsDialogActivity : ComponentActivity() { private var airPodsService: AirPodsService? = null private var isBound = false private var isNoiseControlExpandedState by mutableStateOf(false) private val connection = object : ServiceConnection { override fun onServiceConnected(className: ComponentName, service: IBinder) { val binder = service as AirPodsService.LocalBinder airPodsService = binder.getService() isBound = true Log.d("QSActivity", "Service bound") setContent { LibrePodsTheme { DraggableDismissBox( onDismiss = { finish() }, onlyCollapseWhenClicked = { if (isNoiseControlExpandedState) { isNoiseControlExpandedState = false true } else { false } } ) { if (isBound && airPodsService != null) { NewControlCenterDialogContent( service = airPodsService, isNoiseControlExpanded = isNoiseControlExpandedState, onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } ) } } } } } override fun onServiceDisconnected(arg0: ComponentName) { isBound = false airPodsService = null Log.d("QSActivity", "Service unbound") finish() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL) window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH) window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND) window.setGravity(Gravity.BOTTOM) Intent(this, AirPodsService::class.java).also { intent -> bindService(intent, connection, BIND_AUTO_CREATE) } setContent { LibrePodsTheme { DraggableDismissBox( onDismiss = { finish() }, onlyCollapseWhenClicked = { if (isNoiseControlExpandedState) { isNoiseControlExpandedState = false true } else { false } } ) { if (isBound && airPodsService != null) { NewControlCenterDialogContent( service = airPodsService, isNoiseControlExpanded = isNoiseControlExpandedState, onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } ) } } } } } override fun onDestroy() { super.onDestroy() if (isBound) { unbindService(connection) isBound = false } } } @Composable fun DraggableDismissBox( onDismiss: () -> Unit, onlyCollapseWhenClicked: () -> Boolean, content: @Composable () -> Unit ) { val coroutineScope = rememberCoroutineScope() var dragOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } val dismissThreshold = 400f val animatedOffset = remember { Animatable(0f) } val animatedScale = remember { Animatable(1f) } val animatedAlpha = remember { Animatable(1f) } val backgroundAlpha by animateFloatAsState( targetValue = if (isDragging) { val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f) 1f - dragProgress } else 1f, label = "BackgroundFade" ) LaunchedEffect(isDragging) { if (!isDragging) { if (abs(dragOffset) < dismissThreshold) { val springSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessHigh, visibilityThreshold = 0.1f ) launch { animatedOffset.animateTo(0f, springSpec) } launch { animatedScale.animateTo(1f, springSpec) } launch { animatedAlpha.animateTo(1f, tween(100)) } dragOffset = 0f } } } LaunchedEffect(dragOffset, isDragging) { if (isDragging) { val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f) animatedOffset.snapTo(dragOffset) animatedScale.snapTo(1f - dragProgress * 0.3f) animatedAlpha.snapTo(1f - dragProgress * 0.7f) } } Box( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.5f * backgroundAlpha)) .pointerInput(Unit) { detectVerticalDragGestures( onDragStart = { isDragging = true }, onDragEnd = { isDragging = false if (abs(dragOffset) > dismissThreshold) { coroutineScope.launch { val direction = if (dragOffset > 0) 1f else -1f launch { animatedOffset.animateTo( direction * 1500f, tween(350, easing = FastOutSlowInEasing) ) } launch { animatedScale.animateTo(0.7f, tween(350)) } launch { animatedAlpha.animateTo(0f, tween(250)) } kotlinx.coroutines.delay(350) onDismiss() } } }, onDragCancel = { isDragging = false }, onVerticalDrag = { change, dragAmount -> change.consume() dragOffset += dragAmount } ) } .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { onlyCollapseWhenClicked() }, contentAlignment = Alignment.BottomCenter ) { Box( modifier = Modifier .fillMaxWidth() .graphicsLayer( translationY = animatedOffset.value, scaleX = animatedScale.value, scaleY = animatedScale.value, alpha = animatedAlpha.value ), contentAlignment = Alignment.BottomCenter ) { content() } } } @SuppressLint("UnspecifiedRegisterReceiverFlag") @Composable fun NewControlCenterDialogContent( service: AirPodsService?, isNoiseControlExpanded: Boolean, onNoiseControlExpandedChange: (Boolean) -> Unit ) { val context = LocalContext.current val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val textColor = Color.White var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) } var isConvAwarenessEnabled by remember { mutableStateOf(false) } val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) } val availableModes = remember(isOffModeEnabled) { mutableListOf( NoiseControlMode.TRANSPARENCY, NoiseControlMode.ADAPTIVE, NoiseControlMode.NOISE_CANCELLATION ).apply { if (isOffModeEnabled) { add(0, NoiseControlMode.OFF) } } } val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) } val animatedVolumeFraction by animateFloatAsState( targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(), animationSpec = spring( dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow ), label = "VolumeAnimation" ) var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) } var isDraggingVolume by remember { mutableStateOf(false) } LaunchedEffect(animatedVolumeFraction, isDraggingVolume) { if (!isDraggingVolume) { liveDragFraction = animatedVolumeFraction } } DisposableEffect(service, availableModes) { val ancReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == AirPodsNotifications.ANC_DATA && service != null) { val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1 val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY } if (availableModes.contains(newMode)) { currentAncMode = newMode } else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) { currentAncMode = NoiseControlMode.TRANSPARENCY } Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})") } } } val filter = IntentFilter(AirPodsNotifications.ANC_DATA) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED) } else { context.registerReceiver(ancReceiver, filter) } service?.let { val initialModeOrdinal = it.getANC().minus(1) var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY } if (!availableModes.contains(initialMode)) { initialMode = NoiseControlMode.TRANSPARENCY } currentAncMode = initialMode isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true) Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled") } onDispose { context.unregisterReceiver(ancReceiver) } } DisposableEffect(Unit) { val volumeReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == "android.media.VOLUME_CHANGED_ACTION") { val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) if (newVolume != currentVolumeInt) { currentVolumeInt = newVolume Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt") } } } } val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION") context.registerReceiver(volumeReceiver, filter) onDispose { context.unregisterReceiver(volumeReceiver) } } val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" } Column( modifier = Modifier .fillMaxSize() .background(Color.Transparent) .padding(horizontal = 24.dp) .pointerInput(Unit) { awaitPointerEventScope { while (true) { awaitPointerEvent() } } }, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween ) { if (service != null) { Spacer(modifier = Modifier.weight(1f)) Column( modifier = Modifier .weight(2f) .fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( painter = painterResource(id = R.drawable.airpods), contentDescription = "Device Icon", tint = textColor.copy(alpha = 0.8f), modifier = Modifier.size(48.dp) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = deviceName, color = textColor, fontSize = 16.sp, fontWeight = FontWeight.Medium ) Spacer(modifier = Modifier.height(32.dp)) VerticalVolumeSlider( displayFraction = animatedVolumeFraction, maxVolume = maxVolume, onVolumeChange = { newVolume -> currentVolumeInt = newVolume try { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0) } catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) } }, initialFraction = animatedVolumeFraction, onDragStateChange = { dragging -> isDraggingVolume = dragging }, baseSliderHeight = 400.dp, baseSliderWidth = 145.dp, baseCornerRadius = 48.dp, maxStretchFactor = 1.15f, minCompressionFactor = 0.875f, stretchSensitivity = 0.3f, compressionSensitivity = 0.3f, cornerRadiusChangeFactor = -0.5f, directionalStretchRatio = 0.75f, modifier = Modifier .width(145.dp) .padding(vertical = 8.dp) ) } Spacer(modifier = Modifier.weight(1f)) Box( modifier = Modifier .fillMaxWidth() .padding(bottom = 72.dp) .animateContentSize( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium ) ), contentAlignment = Alignment.Center ) { Crossfade( targetState = isNoiseControlExpanded, animationSpec = tween(durationMillis = 300), label = "NoiseControlCrossfade" ) { expanded -> if (expanded) { ControlCenterNoiseControlSegmentedButton( availableModes = availableModes, selectedMode = currentAncMode, onModeSelected = { newMode -> service.aacpManager.sendControlCommand( identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = newMode.ordinal + 1 ) currentAncMode = newMode }, modifier = Modifier.fillMaxWidth(0.8f) ) } else { Row( modifier = Modifier.fillMaxWidth(0.85f), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) { AdaptiveRainbowBrush } else { null } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { Box( modifier = Modifier .size(IconAreaSize) .clip(CircleShape) .background( brush = noiseControlButtonBrush ?: Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF))) ) .clickable( onClick = { onNoiseControlExpandedChange(true) }, indication = null, interactionSource = remember { MutableInteractionSource() } ), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(id = getModeIconRes(currentAncMode)), contentDescription = getModeLabel(currentAncMode), tint = Color.White, modifier = Modifier.size(32.dp) ) } Spacer(modifier = Modifier.height(8.dp)) Text( text = getModeLabel(currentAncMode), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } Spacer(modifier = Modifier.width(24.dp)) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { Box( modifier = Modifier .size(IconAreaSize) .clip(CircleShape) .background( Brush.linearGradient( colors = listOf( if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E), if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E) ) ) ) .clickable( onClick = { val newState = !isConvAwarenessEnabled service.aacpManager.sendControlCommand( identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, value = newState ) isConvAwarenessEnabled = newState }, indication = null, interactionSource = remember { MutableInteractionSource() } ), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(id = R.drawable.airpods), contentDescription = "Conversational Awareness", tint = Color.White, modifier = Modifier.size(32.dp) ) } Spacer(modifier = Modifier.height(8.dp)) Text( text = "Conversational\nAwareness", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, textAlign = androidx.compose.ui.text.style.TextAlign.Center, lineHeight = 14.sp ) } } } } } } else { Spacer(modifier = Modifier.weight(1f)) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text("Loading...", color = textColor) } Spacer(modifier = Modifier.weight(1f)) } } } private fun getModeIconRes(mode: NoiseControlMode): Int { return when (mode) { NoiseControlMode.OFF -> R.drawable.noise_cancellation NoiseControlMode.TRANSPARENCY -> R.drawable.transparency NoiseControlMode.ADAPTIVE -> R.drawable.adaptive NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation } } private fun getModeLabel(mode: NoiseControlMode): String { return when (mode) { NoiseControlMode.OFF -> "Off" NoiseControlMode.TRANSPARENCY -> "Transparency" NoiseControlMode.ADAPTIVE -> "Adaptive" NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel" } }