From 342745ee2e53881e91b78e56561dcc664fe3dd93 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Tue, 30 Sep 2025 23:53:29 +0530 Subject: [PATCH] android: add accessiblity service for camera control --- android/app/src/main/AndroidManifest.xml | 12 +- .../librepods/composables/AudioSettings.kt | 24 +- .../composables/CallControlSettings.kt | 25 +- .../librepods/composables/NavigationButton.kt | 44 +- .../composables/NoiseControlSettings.kt | 24 +- .../composables/PressAndHoldSettings.kt | 30 +- .../librepods/composables/StyledSelectList.kt | 174 ++++++++ .../screens/AirPodsSettingsScreen.kt | 5 +- .../screens/PressAndHoldSettingsScreen.kt | 410 ++++++------------ .../librepods/services/AirPodsService.kt | 3 +- .../librepods/services/AppListenerService.kt | 51 +++ android/app/src/main/res/values/strings.xml | 3 + .../res/xml/app_listener_service_config.xml | 6 + 13 files changed, 473 insertions(+), 338 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt create mode 100644 android/app/src/main/res/xml/app_listener_service_config.xml diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 43a2d73..0ad1a99 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -117,7 +117,17 @@ - + + + + + + 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) + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index d031a8c..1dd01b4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt index b4cb081..4c5deea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt @@ -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() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt new file mode 100644 index 0000000..0be868b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt @@ -0,0 +1,174 @@ +/* + * 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 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 . + */ + +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, + 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) + ) + } + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index d7e8df3..1c36308 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -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) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt index 18c01d6..5aa5c9c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt @@ -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() + 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 } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 32ffbe9..45da166 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt new file mode 100644 index 0000000..a1ac24d --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt @@ -0,0 +1,51 @@ +/* + * 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 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 . + */ + +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() {} +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4a448a3..2aa4994 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -174,4 +174,7 @@ Found offset please restart the Bluetooth process Digital Assistant On + Camera Remote + Camera Control + 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. diff --git a/android/app/src/main/res/xml/app_listener_service_config.xml b/android/app/src/main/res/xml/app_listener_service_config.xml new file mode 100644 index 0000000..5a5455b --- /dev/null +++ b/android/app/src/main/res/xml/app_listener_service_config.xml @@ -0,0 +1,6 @@ + \ No newline at end of file