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.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)
}
}
}

View File

@@ -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)
)
)
}

View File

@@ -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<SelectItem>,

View File

@@ -24,7 +24,6 @@ enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
CAMERA_SHUTTER,
DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES;
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 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
}
}
}
}

View File

@@ -16,12 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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}")

View File

@@ -177,4 +177,6 @@
<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>
<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>

View File

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