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" />
</intent-filter>
</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
android:name=".receivers.BootReceiver"
android:enabled="true"

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -56,6 +57,7 @@ fun NavigationButton(
name: String,
navController: NavController, onClick: (() -> Unit)? = null,
independent: Boolean = true,
title: String? = null,
description: String? = null,
currentState: String? = null
) {
@@ -63,6 +65,22 @@ fun NavigationButton(
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
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(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
@@ -113,16 +131,22 @@ fun NavigationButton(
)
}
if (description != null) {
Text(
text = description,
style = TextStyle(
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, vertical = 4.dp)
)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text(
text = description,
style = TextStyle(
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 {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
)
)
}
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints(
modifier = Modifier

View File

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

View File

@@ -66,8 +66,10 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
@@ -139,34 +141,25 @@ fun LongPress(navController: NavController, name: String) {
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
LongPressActionElement(
val actionItems = listOf(
SelectItem(
name = stringResource(R.string.noise_control),
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)}
},
isFirst = true,
isLast = false
)
RightDividerNoIcon()
LongPressActionElement(
sharedPreferences.edit { putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) }
}
),
SelectItem(
name = stringResource(R.string.digital_assistant),
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name)}
},
isFirst = false,
isLast = true
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
}
)
}
)
StyledSelectList(items = actionItems)
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp))
@@ -184,36 +177,118 @@ fun LongPress(navController: NavController, name: String) {
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte()
ListeningModeElement(
name = stringResource(R.string.off),
enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation,
isFirst = true)
if (offListeningMode) RightDivider()
ListeningModeElement(
name = stringResource(R.string.transparency),
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
ListeningModeElement(
name = stringResource(R.string.adaptive),
resourceId = R.drawable.adaptive)
RightDivider()
ListeningModeElement(
name = stringResource(R.string.noise_cancellation),
resourceId = R.drawable.noise_cancellation,
isLast = true)
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
Log.d("PressAndHoldSettingsScreen", "Allow Off state: $offListeningModeValue")
val allowOff = offListeningModeValue == 1.toByte()
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
var currentByte by remember { mutableStateOf(initialByte) }
val listeningModeItems = mutableListOf<SelectItem>()
if (allowOff) {
listeningModeItems.add(
SelectItem(
name = stringResource(R.string.off),
description = "Turns off noise management",
iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0,
onClick = {
val bit = 0x01
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
}
)
)
}
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))
Text(
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)}")
}
@Composable
fun ListeningModeElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val bit = when (name) {
"Off" -> 0x01
"Transparency" -> 0x02
"Noise Cancellation" -> 0x04
"Adaptive" -> 0x08
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)
)
}
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++
return count
}

View File

@@ -126,7 +126,7 @@ import java.nio.ByteOrder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
const val TAG = "AirPodsService"
private const val TAG = "AirPodsService"
object ServiceManager {
@ExperimentalEncodingApi
@@ -379,7 +379,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
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(
"show_phone_battery_in_widget",
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="digital_assistant">Digital Assistant</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>

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"/>