diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 6ac3d34..159ebe6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -110,6 +110,7 @@ import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen +import me.kavishdevar.librepods.screens.CameraControlScreen import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen @@ -394,6 +395,9 @@ fun Main() { composable("adaptive_strength") { AdaptiveStrengthScreen(navController) } + composable("camera_control") { + CameraControlScreen(navController) + } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt index 9c23b01..616e8ac 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt @@ -83,8 +83,7 @@ fun CallControlSettings(hazeState: HazeState) { fontSize = 14.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f) - ), - modifier = Modifier.padding(16.dp, bottom = 4.dp) + ) ) } 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 index 0be868b..58e196c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt @@ -62,6 +62,16 @@ data class SelectItem( val enabled: Boolean = true ) +data class SelectItem2( + 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, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt index fabe01a..206fc32 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt @@ -24,7 +24,6 @@ enum class StemAction { PLAY_PAUSE, PREVIOUS_TRACK, NEXT_TRACK, - CAMERA_SHUTTER, DIGITAL_ASSISTANT, CYCLE_NOISE_CONTROL_MODES; companion object { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt new file mode 100644 index 0000000..1d107a2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt @@ -0,0 +1,154 @@ +/* + * 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.screens + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.view.accessibility.AccessibilityManager +import android.accessibilityservice.AccessibilityServiceInfo +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.core.content.edit +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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.composables.StyledSlider +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.services.AppListenerService +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: Job? = null + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun CameraControlScreen(navController: NavController) { + val isDarkTheme = isSystemInDarkTheme() + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + val service = ServiceManager.getService()!! + var currentCameraAction by remember { + mutableStateOf( + sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) } + ) + } + + fun isAppListenerServiceEnabled(context: Context): Boolean { + val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + val serviceComponent = ComponentName(context, AppListenerService::class.java) + return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className } + } + + val cameraOptions = listOf( + SelectItem( + name = stringResource(R.string.off), + selected = currentCameraAction == null, + onClick = { + sharedPreferences.edit { remove("camera_action") } + currentCameraAction = null + } + ), + SelectItem( + name = stringResource(R.string.press_once), + selected = currentCameraAction == StemPressType.SINGLE_PRESS, + onClick = { + if (!isAppListenerServiceEnabled(context)) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } else { + sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) } + currentCameraAction = StemPressType.SINGLE_PRESS + } + } + ), + SelectItem( + name = stringResource(R.string.press_and_hold_airpods), + selected = currentCameraAction == StemPressType.LONG_PRESS, + onClick = { + if (!isAppListenerServiceEnabled(context)) { + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + } else { + sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) } + currentCameraAction = StemPressType.LONG_PRESS + } + } + ) + ) + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.camera_control), + navigationButton = { + StyledIconButton( + onClick = { navController.popBackStack() }, + icon = "􀯶", + darkMode = isDarkTheme, + backdrop = backdrop + ) + } + ) { spacerHeight -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + StyledSelectList(items = cameraOptions) + } + } +} 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 45da166..0f3e72c 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 @@ -189,6 +189,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, + + var cameraAction: AACPManager.Companion.StemPressType? = null, ) private lateinit var config: ServiceConfig @@ -469,6 +471,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "right_long_press_action", StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name ) + if (!contains("camera_action")) putString("camera_action", "SINGLE_PRESS") } } @@ -735,22 +738,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Suppress("unused") fun cameraOpened() { Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled") - val isCameraShutterUsed = listOf( - config.leftSinglePressAction, - config.rightSinglePressAction, - config.leftDoublePressAction, - config.rightDoublePressAction, - config.leftTriplePressAction, - config.rightTriplePressAction, - config.leftLongPressAction, - config.rightLongPressAction - ).any { it == StemAction.CAMERA_SHUTTER } - - if (isCameraShutterUsed) { - Log.d(TAG, "Camera opened, setting up stem actions") - cameraActive = true - setupStemActions(isCameraActive = true) - } + cameraActive = true + setupStemActions() } @Suppress("unused") @@ -761,27 +750,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun isCustomAction( action: StemAction?, - default: StemAction?, - isCameraActive: Boolean = false + default: StemAction? ): Boolean { - Log.d(TAG, "Checking if action $action is custom against default $default, camera active: $isCameraActive") - return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive) + return action != default } - fun setupStemActions(isCameraActive: Boolean = false) { + fun setupStemActions() { val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS] val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS] val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] - val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) || - isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive) - val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) || - isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive) - val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) || - isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive) - val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) || - isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive) + val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault) || + isCustomAction(config.rightSinglePressAction, singlePressDefault) || + (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS) + val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault) || + isCustomAction(config.rightDoublePressAction, doublePressDefault) + val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault) || + isCustomAction(config.rightTriplePressAction, triplePressDefault) + val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault) || + isCustomAction(config.rightLongPressAction, longPressDefault) || + (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) Log.d(TAG, "Setting up stem actions: " + "Single Press Customized: $singlePressCustomized, " + "Double Press Customized: $doublePressCustomized, " + @@ -963,12 +952,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onStemPressReceived(stemPress: ByteArray) { val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) - Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud") - - val action = getActionFor(bud, stemPressType) - Log.d("AirPodsParser", "$bud $stemPressType action: $action") - - action?.let { executeStemAction(it) } + Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}") + if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) { + Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) + } else { + val action = getActionFor(bud, stemPressType) + Log.d("AirPodsParser", "$bud $stemPressType action: $action") + action?.let { executeStemAction(it) } + } } override fun onAudioSourceReceived(audioSource: ByteArray) { Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}") @@ -1024,7 +1015,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() StemAction.NEXT_TRACK -> MediaController.sendNextTrack() - StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27")) StemAction.DIGITAL_ASSISTANT -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply { @@ -1171,7 +1161,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!, leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!, - rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!! + rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!, + + cameraAction = sharedPreferences.getString("camera_action", null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }, ) } @@ -1252,6 +1244,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList )!! setupStemActions() } + "camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) } } if (key == "mac_address") { @@ -1780,6 +1773,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList handleIncomingCallOnceConnected = false } } + } } 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 index a1ac24d..6ef6716 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt @@ -16,12 +16,15 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.services import android.accessibilityservice.AccessibilityService import android.util.Log import android.view.accessibility.AccessibilityEvent +import kotlin.io.encoding.ExperimentalEncodingApi private const val TAG="AppListenerService" @@ -35,12 +38,28 @@ val cameraPackages = setOf( "com.nothing.camera" ) +var cameraOpen = false + 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") + if (pkg == "com.android.systemui") return // after camera opens, systemui is opened, probably for the privacy indicators + Log.d(TAG, "Package: $pkg, cameraOpen: $cameraOpen") + if (pkg in cameraPackages) { + Log.d(TAG, "Camera app opened: $pkg") + if (!cameraOpen) cameraOpen = true + ServiceManager.getService()?.cameraOpened() + } else { + if (cameraOpen) { + cameraOpen = false + ServiceManager.getService()?.cameraClosed() + } else { + Log.d(TAG, "ignoring") + } + } + // Log.d(TAG, "Opened: $pkg") } } catch(e: Exception) { Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}") diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2aa4994..e0ead26 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -177,4 +177,6 @@ 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. + Camera listener + Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods. 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 index 5a5455b..866d9bb 100644 --- a/android/app/src/main/res/xml/app_listener_service_config.xml +++ b/android/app/src/main/res/xml/app_listener_service_config.xml @@ -3,4 +3,7 @@ android:accessibilityFeedbackType="feedbackGeneric" android:notificationTimeout="50" android:canRetrieveWindowContent="false" + android:label="@string/app_listener_service_label" + android:description="@string/app_listener_service_description" + android:settingsActivity="me.kavishdevar.librepods.MainActivity" android:accessibilityFlags="flagReportViewIds"/> \ No newline at end of file