android: add accessiblity service for camera control

This commit is contained in:
Kavish Devar
2025-09-30 23:53:29 +05:30
parent 8b49440d6b
commit 342745ee2e
13 changed files with 473 additions and 338 deletions

View File

@@ -117,7 +117,17 @@
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".services.AppListenerService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/app_listener_service_config" />
</service>
<receiver <receiver
android:name=".receivers.BootReceiver" android:name=".receivers.BootReceiver"
android:enabled="true" android:enabled="true"

View File

@@ -22,6 +22,7 @@ package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -50,15 +51,20 @@ fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
Text( Box(
text = stringResource(R.string.audio), modifier = Modifier
style = TextStyle( .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
fontSize = 14.sp, .padding(horizontal = 16.dp, vertical = 4.dp)
fontWeight = FontWeight.Bold, ){
color = textColor.copy(alpha = 0.6f) Text(
), text = stringResource(R.string.audio),
modifier = Modifier.padding(16.dp, bottom = 4.dp) style = TextStyle(
) fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)

View File

@@ -72,16 +72,21 @@ fun CallControlSettings(hazeState: HazeState) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
Text( modifier = Modifier
text = stringResource(R.string.call_controls), .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
style = TextStyle( .padding(horizontal = 16.dp, vertical = 4.dp)
fontSize = 14.sp, ){
fontWeight = FontWeight.Bold, Text(
color = textColor.copy(alpha = 0.6f) text = stringResource(R.string.call_controls),
), style = TextStyle(
modifier = Modifier.padding(16.dp, bottom = 4.dp) fontSize = 14.sp,
) fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(16.dp, bottom = 4.dp)
)
}
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -23,6 +23,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -56,6 +57,7 @@ fun NavigationButton(
name: String, name: String,
navController: NavController, onClick: (() -> Unit)? = null, navController: NavController, onClick: (() -> Unit)? = null,
independent: Boolean = true, independent: Boolean = true,
title: String? = null,
description: String? = null, description: String? = null,
currentState: String? = null currentState: String? = null
) { ) {
@@ -63,6 +65,22 @@ fun NavigationButton(
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Column { Column {
if (title != null) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
){
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
)
)
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
@@ -113,16 +131,22 @@ fun NavigationButton(
) )
} }
if (description != null) { if (description != null) {
Text( Box(
text = description, modifier = Modifier
style = TextStyle( .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
fontSize = 12.sp, .padding(horizontal = 16.dp, vertical = 4.dp),
fontWeight = FontWeight.Light, ) {
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), Text(
fontFamily = FontFamily(Font(R.font.sf_pro)) text = description,
), style = TextStyle(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) fontSize = 12.sp,
) fontWeight = FontWeight.Light,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
// modifier = Modifier.padding(horizontal = 16.dp)
)
}
} }
} }
} }

View File

@@ -179,16 +179,20 @@ fun NoiseControlSettings(
} else { } else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
} }
Box(
Text( modifier = Modifier
text = stringResource(R.string.noise_control), .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
style = TextStyle( .padding(horizontal = 16.dp, vertical = 4.dp)
fontSize = 14.sp, ){
fontWeight = FontWeight.Bold, Text(
color = textColor.copy(alpha = 0.6f), text = stringResource(R.string.noise_control),
), style = TextStyle(
modifier = Modifier.padding(8.dp, bottom = 2.dp) fontSize = 14.sp,
) fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
)
)
}
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH") @Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier

View File

@@ -22,6 +22,7 @@ import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -70,20 +71,21 @@ fun PressAndHoldSettings(navController: NavController) {
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
Box(
Text( modifier = Modifier
text = stringResource(R.string.press_and_hold_airpods), .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
style = TextStyle( .padding(horizontal = 16.dp, vertical = 4.dp)
fontSize = 14.sp, ){
fontWeight = FontWeight.Bold, Text(
color = textColor.copy(alpha = 0.6f), text = stringResource(R.string.press_and_hold_airpods),
fontFamily = FontFamily(Font(R.font.sf_pro)) style = TextStyle(
), fontSize = 14.sp,
modifier = Modifier.padding(16.dp, bottom = 4.dp) fontWeight = FontWeight.Bold,
) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
Spacer(modifier = Modifier.height(1.dp)) )
)
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -0,0 +1,174 @@
/*
* LibrePods - AirPods liberated from Apples 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
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.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
data class SelectItem(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
@Composable
fun StyledSelectList(
items: List<SelectItem>,
modifier: Modifier = Modifier
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
Column(
modifier = modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val visibleItems = items.filter { it.enabled }
visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0
val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null
val shape = when {
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp)
}
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(if (hasIcon) 72.dp else 55.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
itemBackgroundColor = backgroundColor
item.onClick()
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
if (hasIcon) {
Icon(
painter = painterResource(item.iconRes!!),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
.height(48.dp)
.wrapContentWidth()
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp)
.padding(start = if (hasIcon) 8.dp else 4.dp)
) {
Text(
item.name,
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
item.description?.let {
Text(
it,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
}
}
val floatAnimateState by animateFloatAsState(
targetValue = if (item.selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
}
if (!isLast) {
if (hasIcon) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 72.dp, end = 20.dp)
)
} else {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(start = 20.dp, end = 20.dp)
)
}
}
}
}
}

View File

@@ -246,7 +246,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
item { Spacer(modifier = Modifier.height(32.dp)) } item { Spacer(modifier = Modifier.height(32.dp)) }
item { NavigationButton(to = "hearing_aid", stringResource(R.string.hearing_aid), navController) } item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item { NoiseControlSettings(service = service) } item { NoiseControlSettings(service = service) }
@@ -257,7 +257,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
item { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item { CallControlSettings(hazeState = hazeState) } item { CallControlSettings(hazeState = hazeState) }
// camera control goes here, airpods side is done, i just need to figure out how to listen to app open/close events item { Spacer(modifier = Modifier.height(16.dp)) }
item { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) } item { Spacer(modifier = Modifier.height(16.dp)) }
item { AudioSettings(navController = navController) } item { AudioSettings(navController = navController) }

View File

@@ -66,8 +66,10 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -139,34 +141,25 @@ fun LongPress(navController: NavController, name: String) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
Column( val actionItems = listOf(
modifier = Modifier SelectItem(
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
LongPressActionElement(
name = stringResource(R.string.noise_control), name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = { onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)} sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
}, }
isFirst = true, ),
isLast = false SelectItem(
)
RightDividerNoIcon()
LongPressActionElement(
name = stringResource(R.string.digital_assistant), name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT, selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = { onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name)} sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
}, }
isFirst = false,
isLast = true
) )
} )
StyledSelectList(items = actionItems)
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -184,36 +177,118 @@ fun LongPress(navController: NavController, name: String) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Column( val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
modifier = Modifier it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
.fillMaxWidth() }?.value?.takeIf { it.isNotEmpty() }?.get(0)
.background(backgroundColor, RoundedCornerShape(28.dp)), Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
horizontalAlignment = Alignment.CenterHorizontally val allowOff = offListeningModeValue == 1.toByte()
) { Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
}?.value?.takeIf { it.isNotEmpty() }?.get(0) it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
val offListeningMode = offListeningModeValue == 1.toByte() }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
ListeningModeElement( var currentByte by remember { mutableStateOf(initialByte) }
name = stringResource(R.string.off),
enabled = offListeningMode, val listeningModeItems = mutableListOf<SelectItem>()
resourceId = R.drawable.noise_cancellation, if (allowOff) {
isFirst = true) listeningModeItems.add(
if (offListeningMode) RightDivider() SelectItem(
ListeningModeElement( name = stringResource(R.string.off),
name = stringResource(R.string.transparency), description = "Turns off noise management",
resourceId = R.drawable.transparency, iconRes = R.drawable.noise_cancellation,
isFirst = !offListeningMode) selected = (currentByte and 0x01) != 0,
RightDivider() onClick = {
ListeningModeElement( val bit = 0x01
name = stringResource(R.string.adaptive), val newValue = if ((currentByte and bit) != 0) {
resourceId = R.drawable.adaptive) val temp = currentByte and bit.inv()
RightDivider() if (countEnabledModes(temp) >= 2) temp else currentByte
ListeningModeElement( } else {
name = stringResource(R.string.noise_cancellation), currentByte or bit
resourceId = R.drawable.noise_cancellation, }
isLast = true) ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
)
)
} }
listeningModeItems.addAll(listOf(
SelectItem(
name = stringResource(R.string.transparency),
description = "Lets in external sounds",
iconRes = R.drawable.transparency,
selected = (currentByte and 0x02) != 0,
onClick = {
val bit = 0x02
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
),
SelectItem(
name = stringResource(R.string.adaptive),
description = "Dynamically adjust external noise",
iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0,
onClick = {
val bit = 0x08
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
),
SelectItem(
name = stringResource(R.string.noise_cancellation),
description = "Blocks out external sounds",
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x04) != 0,
onClick = {
val bit = 0x04
val newValue = if ((currentByte and bit) != 0) {
val temp = currentByte and bit.inv()
if (countEnabledModes(temp) >= 2) temp else currentByte
} else {
currentByte or bit
}
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
newValue.toByte()
)
sharedPreferences.edit {
putInt("long_press_byte", newValue)
}
currentByte = newValue
}
)
))
StyledSelectList(items = listeningModeItems)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.press_and_hold_noise_control_description), text = stringResource(R.string.press_and_hold_noise_control_description),
@@ -234,236 +309,11 @@ fun LongPress(navController: NavController, name: String) {
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}") }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
} }
@Composable fun countEnabledModes(byteValue: Int): Int {
fun ListeningModeElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { var count = 0
val bit = when (name) { if ((byteValue and 0x01) != 0) count++
"Off" -> 0x01 if ((byteValue and 0x02) != 0) count++
"Transparency" -> 0x02 if ((byteValue and 0x04) != 0) count++
"Noise Cancellation" -> 0x04 if ((byteValue and 0x08) != 0) count++
"Adaptive" -> 0x08 return count
else -> -1
}
val context = LocalContext.current
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte",
0b0101
)
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
val isChecked = (byteValue.toInt() and bit) != 0
val checked = remember { mutableStateOf(isChecked) }
Log.d("PressAndHoldSettingsScreen", "ListeningModeElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
val darkMode = isSystemInDarkTheme()
val textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) {
"Off" -> "Turns off noise management"
"Noise Cancellation" -> "Blocks out external sounds"
"Transparency" -> "Lets in external sounds"
"Adaptive" -> "Dynamically adjust external noise"
else -> ""
}
fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
return count
}
fun valueChanged(value: Boolean = !checked.value) {
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
if (!value) {
val newValue = currentValue and bit.inv()
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
val modeCount = countEnabledModes(newValue)
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
if (modeCount < 2) {
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
return
}
val updatedByte = newValue.toByte()
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit {
putInt("long_press_byte", newValue)}
checked.value = false
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
} else {
val newValue = currentValue or bit
val updatedByte = newValue.toByte()
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit {
putInt("long_press_byte", newValue)
}
checked.value = true
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
}
}
val shape = when {
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (!enabled) {
valueChanged(false)
} else {
Row(
modifier = Modifier
.height(72.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
valueChanged()
},
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
painter = painterResource(resourceId),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
.height(48.dp)
.wrapContentWidth()
)
Column (
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp)
.padding(start = 8.dp)
)
{
Text(
name,
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
Text (
desc,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
}
val floatAnimateState by animateFloatAsState(
targetValue = if (checked.value) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
@Composable
fun LongPressActionElement(
name: String,
selected: Boolean,
onClick: () -> Unit,
isFirst: Boolean = false,
isLast: Boolean = false
) {
val darkMode = isSystemInDarkTheme()
val shape = when {
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(55.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
onClick()
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val isDarkTheme = isSystemInDarkTheme()
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White else Color.Black,
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
)
val floatAnimateState by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Text(
text = "􀆅",
style = TextStyle(
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color(0xFF007AFF).copy(alpha = floatAnimateState)
),
modifier = Modifier.padding(end = 4.dp)
)
}
} }

View File

@@ -126,7 +126,7 @@ import java.nio.ByteOrder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
const val TAG = "AirPodsService" private const val TAG = "AirPodsService"
object ServiceManager { object ServiceManager {
@ExperimentalEncodingApi @ExperimentalEncodingApi
@@ -379,7 +379,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
true true
) )
if (!contains("long_press_nc")) putBoolean("long_press_nc", true) if (!contains("long_press_nc")) putBoolean("long_press_nc", true)
if (!contains("off_listening_mode")) putBoolean("off_listening_mode", false)
if (!contains("show_phone_battery_in_widget")) putBoolean( if (!contains("show_phone_battery_in_widget")) putBoolean(
"show_phone_battery_in_widget", "show_phone_battery_in_widget",
true true

View File

@@ -0,0 +1,51 @@
/*
* LibrePods - AirPods liberated from Apples 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.services
import android.accessibilityservice.AccessibilityService
import android.util.Log
import android.view.accessibility.AccessibilityEvent
private const val TAG="AppListenerService"
val cameraPackages = setOf(
"com.google.android.GoogleCamera",
"com.sec.android.app.camera",
"com.android.camera",
"com.oppo.camera",
"com.motorola.camera2",
"org.codeaurora.snapcam",
"com.nothing.camera"
)
class AppListenerService : AccessibilityService() {
override fun onAccessibilityEvent(ev: AccessibilityEvent?) {
try {
if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val pkg = ev.packageName?.toString() ?: return
Log.d(TAG, "Opened: $pkg")
}
} catch(e: Exception) {
Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}")
}
}
override fun onInterrupt() {}
}

View File

@@ -174,4 +174,7 @@
<string name="found_offset_restart_bluetooth">Found offset please restart the Bluetooth process</string> <string name="found_offset_restart_bluetooth">Found offset please restart the Bluetooth process</string>
<string name="digital_assistant">Digital Assistant</string> <string name="digital_assistant">Digital Assistant</string>
<string name="on">On</string> <string name="on">On</string>
<string name="camera_remote">Camera Remote</string>
<string name="camera_control">Camera Control</string>
<string name="camera_control_description">Capture a photo, start or stop recording, and more using either Press Once or Press and Hold. When using AirPods for camera actions, if you select Press Once, media control gestures will be unavailable, and if you select Press and Hold, listening mode and Digital Assistant gestures will be unavailable.</string>
</resources> </resources>

View File

@@ -0,0 +1,6 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="50"
android:canRetrieveWindowContent="false"
android:accessibilityFlags="flagReportViewIds"/>