android: add camera control, finally

i got too lazy to find out how to listen to app openings earlier, wasn't too hard
This commit is contained in:
Kavish Devar
2025-10-01 01:10:37 +05:30
parent 342745ee2e
commit c7dc545ed4
9 changed files with 225 additions and 41 deletions

View File

@@ -110,6 +110,7 @@ import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
@@ -394,6 +395,9 @@ fun Main() {
composable("adaptive_strength") { composable("adaptive_strength") {
AdaptiveStrengthScreen(navController) AdaptiveStrengthScreen(navController)
} }
composable("camera_control") {
CameraControlScreen(navController)
}
} }
} }

View File

@@ -83,8 +83,7 @@ fun CallControlSettings(hazeState: HazeState) {
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f)
), )
modifier = Modifier.padding(16.dp, bottom = 4.dp)
) )
} }

View File

@@ -62,6 +62,16 @@ data class SelectItem(
val enabled: Boolean = true 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 @Composable
fun StyledSelectList( fun StyledSelectList(
items: List<SelectItem>, items: List<SelectItem>,

View File

@@ -24,7 +24,6 @@ enum class StemAction {
PLAY_PAUSE, PLAY_PAUSE,
PREVIOUS_TRACK, PREVIOUS_TRACK,
NEXT_TRACK, NEXT_TRACK,
CAMERA_SHUTTER,
DIGITAL_ASSISTANT, DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES; CYCLE_NOISE_CONTROL_MODES;
companion object { companion object {

View File

@@ -0,0 +1,154 @@
/*
* 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.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)
}
}
}

View File

@@ -189,6 +189,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var rightLongPressAction: 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 private lateinit var config: ServiceConfig
@@ -469,6 +471,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"right_long_press_action", "right_long_press_action",
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name 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") @Suppress("unused")
fun cameraOpened() { fun cameraOpened() {
Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled") 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 cameraActive = true
setupStemActions(isCameraActive = true) setupStemActions()
}
} }
@Suppress("unused") @Suppress("unused")
@@ -761,27 +750,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun isCustomAction( fun isCustomAction(
action: StemAction?, action: StemAction?,
default: StemAction?, default: StemAction?
isCameraActive: Boolean = false
): Boolean { ): Boolean {
Log.d(TAG, "Checking if action $action is custom against default $default, camera active: $isCameraActive") return action != default
return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive)
} }
fun setupStemActions(isCameraActive: Boolean = false) { fun setupStemActions() {
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS] val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS] val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS] val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS] val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) || val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault) ||
isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive) isCustomAction(config.rightSinglePressAction, singlePressDefault) ||
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS)
isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive) val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault) ||
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) || isCustomAction(config.rightDoublePressAction, doublePressDefault)
isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive) val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault) ||
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) || isCustomAction(config.rightTriplePressAction, triplePressDefault)
isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive) val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault) ||
isCustomAction(config.rightLongPressAction, longPressDefault) ||
(cameraActive && config.cameraAction == StemPressType.LONG_PRESS)
Log.d(TAG, "Setting up stem actions: " + Log.d(TAG, "Setting up stem actions: " +
"Single Press Customized: $singlePressCustomized, " + "Single Press Customized: $singlePressCustomized, " +
"Double Press Customized: $doublePressCustomized, " + "Double Press Customized: $doublePressCustomized, " +
@@ -963,13 +952,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onStemPressReceived(stemPress: ByteArray) { override fun onStemPressReceived(stemPress: ByteArray) {
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress) val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud") 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) val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action") Log.d("AirPodsParser", "$bud $stemPressType action: $action")
action?.let { executeStemAction(it) } action?.let { executeStemAction(it) }
} }
}
override fun onAudioSourceReceived(audioSource: ByteArray) { override fun onAudioSourceReceived(audioSource: ByteArray) {
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}") Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) { if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
@@ -1024,7 +1015,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause() StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack() StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
StemAction.NEXT_TRACK -> MediaController.sendNextTrack() StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
StemAction.DIGITAL_ASSISTANT -> { StemAction.DIGITAL_ASSISTANT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply { 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")!!, 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")!!, 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() setupStemActions()
} }
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }
} }
if (key == "mac_address") { if (key == "mac_address") {
@@ -1780,6 +1773,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
handleIncomingCallOnceConnected = false handleIncomingCallOnceConnected = false
} }
} }
} }
} }

View File

@@ -16,12 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.services package me.kavishdevar.librepods.services
import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityService
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityEvent
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG="AppListenerService" private const val TAG="AppListenerService"
@@ -35,12 +38,28 @@ val cameraPackages = setOf(
"com.nothing.camera" "com.nothing.camera"
) )
var cameraOpen = false
class AppListenerService : AccessibilityService() { class AppListenerService : AccessibilityService() {
override fun onAccessibilityEvent(ev: AccessibilityEvent?) { override fun onAccessibilityEvent(ev: AccessibilityEvent?) {
try { try {
if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val pkg = ev.packageName?.toString() ?: return 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) { } catch(e: Exception) {
Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}") Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}")

View File

@@ -177,4 +177,6 @@
<string name="camera_remote">Camera Remote</string> <string name="camera_remote">Camera Remote</string>
<string name="camera_control">Camera Control</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> <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>
<string name="app_listener_service_label">Camera listener</string>
<string name="app_listener_service_description">Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods.</string>
</resources> </resources>

View File

@@ -3,4 +3,7 @@
android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFeedbackType="feedbackGeneric"
android:notificationTimeout="50" android:notificationTimeout="50"
android:canRetrieveWindowContent="false" 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"/> android:accessibilityFlags="flagReportViewIds"/>