mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-31 15:19:11 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -24,7 +24,6 @@ enum class StemAction {
|
||||
PLAY_PAUSE,
|
||||
PREVIOUS_TRACK,
|
||||
NEXT_TRACK,
|
||||
CAMERA_SHUTTER,
|
||||
DIGITAL_ASSISTANT,
|
||||
CYCLE_NOISE_CONTROL_MODES;
|
||||
companion object {
|
||||
|
||||
@@ -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 <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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
Reference in New Issue
Block a user