Skip to content

Commit c7dc545

Browse files
committed
android: add camera control, finally
i got too lazy to find out how to listen to app openings earlier, wasn't too hard
1 parent 342745e commit c7dc545

9 files changed

Lines changed: 225 additions & 41 deletions

File tree

android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
110110
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
111111
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
112112
import me.kavishdevar.librepods.screens.AppSettingsScreen
113+
import me.kavishdevar.librepods.screens.CameraControlScreen
113114
import me.kavishdevar.librepods.screens.DebugScreen
114115
import me.kavishdevar.librepods.screens.HeadTrackingScreen
115116
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
@@ -394,6 +395,9 @@ fun Main() {
394395
composable("adaptive_strength") {
395396
AdaptiveStrengthScreen(navController)
396397
}
398+
composable("camera_control") {
399+
CameraControlScreen(navController)
400+
}
397401
}
398402
}
399403

android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,7 @@ fun CallControlSettings(hazeState: HazeState) {
8383
fontSize = 14.sp,
8484
fontWeight = FontWeight.Bold,
8585
color = textColor.copy(alpha = 0.6f)
86-
),
87-
modifier = Modifier.padding(16.dp, bottom = 4.dp)
86+
)
8887
)
8988
}
9089

android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ data class SelectItem(
6262
val enabled: Boolean = true
6363
)
6464

65+
data class SelectItem2(
66+
val name: String,
67+
val description: String? = null,
68+
val iconRes: Int? = null,
69+
val selected: () -> Boolean,
70+
val onClick: () -> Unit,
71+
val enabled: Boolean = true
72+
)
73+
74+
6575
@Composable
6676
fun StyledSelectList(
6777
items: List<SelectItem>,

android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ enum class StemAction {
2424
PLAY_PAUSE,
2525
PREVIOUS_TRACK,
2626
NEXT_TRACK,
27-
CAMERA_SHUTTER,
2827
DIGITAL_ASSISTANT,
2928
CYCLE_NOISE_CONTROL_MODES;
3029
companion object {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* LibrePods - AirPods liberated from Apple’s ecosystem
3+
*
4+
* Copyright (C) 2025 LibrePods contributors
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package me.kavishdevar.librepods.screens
20+
21+
import android.annotation.SuppressLint
22+
import android.content.ComponentName
23+
import android.content.Context
24+
import android.content.Intent
25+
import android.provider.Settings
26+
import android.view.accessibility.AccessibilityManager
27+
import android.accessibilityservice.AccessibilityServiceInfo
28+
import androidx.compose.foundation.isSystemInDarkTheme
29+
import androidx.compose.foundation.layout.Arrangement
30+
import androidx.compose.foundation.layout.Column
31+
import androidx.compose.foundation.layout.Spacer
32+
import androidx.compose.foundation.layout.fillMaxSize
33+
import androidx.compose.foundation.layout.height
34+
import androidx.compose.foundation.layout.padding
35+
import androidx.compose.material3.ExperimentalMaterial3Api
36+
import androidx.compose.runtime.Composable
37+
import androidx.compose.runtime.DisposableEffect
38+
import androidx.compose.runtime.LaunchedEffect
39+
import androidx.compose.runtime.mutableFloatStateOf
40+
import androidx.compose.runtime.getValue
41+
import androidx.compose.runtime.mutableStateOf
42+
import androidx.compose.runtime.remember
43+
import androidx.compose.runtime.setValue
44+
import androidx.compose.ui.Modifier
45+
import androidx.compose.ui.platform.LocalContext
46+
import androidx.compose.ui.res.stringResource
47+
import androidx.compose.ui.unit.dp
48+
import androidx.navigation.NavController
49+
import androidx.core.content.edit
50+
import com.kyant.backdrop.backdrops.layerBackdrop
51+
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
52+
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
53+
import kotlinx.coroutines.CoroutineScope
54+
import kotlinx.coroutines.Dispatchers
55+
import kotlinx.coroutines.Job
56+
import kotlinx.coroutines.delay
57+
import kotlinx.coroutines.launch
58+
import me.kavishdevar.librepods.R
59+
import me.kavishdevar.librepods.composables.SelectItem
60+
import me.kavishdevar.librepods.composables.StyledIconButton
61+
import me.kavishdevar.librepods.composables.StyledScaffold
62+
import me.kavishdevar.librepods.composables.StyledSelectList
63+
import me.kavishdevar.librepods.composables.StyledSlider
64+
import me.kavishdevar.librepods.services.ServiceManager
65+
import me.kavishdevar.librepods.services.AppListenerService
66+
import me.kavishdevar.librepods.utils.AACPManager
67+
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
68+
import kotlin.io.encoding.ExperimentalEncodingApi
69+
70+
private var debounceJob: Job? = null
71+
72+
@SuppressLint("DefaultLocale")
73+
@ExperimentalHazeMaterialsApi
74+
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
75+
@Composable
76+
fun CameraControlScreen(navController: NavController) {
77+
val isDarkTheme = isSystemInDarkTheme()
78+
val context = LocalContext.current
79+
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
80+
81+
val service = ServiceManager.getService()!!
82+
var currentCameraAction by remember {
83+
mutableStateOf(
84+
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
85+
)
86+
}
87+
88+
fun isAppListenerServiceEnabled(context: Context): Boolean {
89+
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
90+
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
91+
val serviceComponent = ComponentName(context, AppListenerService::class.java)
92+
return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className }
93+
}
94+
95+
val cameraOptions = listOf(
96+
SelectItem(
97+
name = stringResource(R.string.off),
98+
selected = currentCameraAction == null,
99+
onClick = {
100+
sharedPreferences.edit { remove("camera_action") }
101+
currentCameraAction = null
102+
}
103+
),
104+
SelectItem(
105+
name = stringResource(R.string.press_once),
106+
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
107+
onClick = {
108+
if (!isAppListenerServiceEnabled(context)) {
109+
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
110+
} else {
111+
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
112+
currentCameraAction = StemPressType.SINGLE_PRESS
113+
}
114+
}
115+
),
116+
SelectItem(
117+
name = stringResource(R.string.press_and_hold_airpods),
118+
selected = currentCameraAction == StemPressType.LONG_PRESS,
119+
onClick = {
120+
if (!isAppListenerServiceEnabled(context)) {
121+
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
122+
} else {
123+
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
124+
currentCameraAction = StemPressType.LONG_PRESS
125+
}
126+
}
127+
)
128+
)
129+
130+
val backdrop = rememberLayerBackdrop()
131+
132+
StyledScaffold(
133+
title = stringResource(R.string.camera_control),
134+
navigationButton = {
135+
StyledIconButton(
136+
onClick = { navController.popBackStack() },
137+
icon = "􀯶",
138+
darkMode = isDarkTheme,
139+
backdrop = backdrop
140+
)
141+
}
142+
) { spacerHeight ->
143+
Column(
144+
modifier = Modifier
145+
.fillMaxSize()
146+
.layerBackdrop(backdrop)
147+
.padding(horizontal = 16.dp),
148+
verticalArrangement = Arrangement.spacedBy(16.dp)
149+
) {
150+
Spacer(modifier = Modifier.height(spacerHeight))
151+
StyledSelectList(items = cameraOptions)
152+
}
153+
}
154+
}

android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt

Lines changed: 31 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
189189

190190
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
191191
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
192+
193+
var cameraAction: AACPManager.Companion.StemPressType? = null,
192194
)
193195

194196
private lateinit var config: ServiceConfig
@@ -469,6 +471,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
469471
"right_long_press_action",
470472
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name
471473
)
474+
if (!contains("camera_action")) putString("camera_action", "SINGLE_PRESS")
472475

473476
}
474477
}
@@ -735,22 +738,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
735738
@Suppress("unused")
736739
fun cameraOpened() {
737740
Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled")
738-
val isCameraShutterUsed = listOf(
739-
config.leftSinglePressAction,
740-
config.rightSinglePressAction,
741-
config.leftDoublePressAction,
742-
config.rightDoublePressAction,
743-
config.leftTriplePressAction,
744-
config.rightTriplePressAction,
745-
config.leftLongPressAction,
746-
config.rightLongPressAction
747-
).any { it == StemAction.CAMERA_SHUTTER }
748-
749-
if (isCameraShutterUsed) {
750-
Log.d(TAG, "Camera opened, setting up stem actions")
751-
cameraActive = true
752-
setupStemActions(isCameraActive = true)
753-
}
741+
cameraActive = true
742+
setupStemActions()
754743
}
755744

756745
@Suppress("unused")
@@ -761,27 +750,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
761750

762751
fun isCustomAction(
763752
action: StemAction?,
764-
default: StemAction?,
765-
isCameraActive: Boolean = false
753+
default: StemAction?
766754
): Boolean {
767-
Log.d(TAG, "Checking if action $action is custom against default $default, camera active: $isCameraActive")
768-
return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive)
755+
return action != default
769756
}
770757

771-
fun setupStemActions(isCameraActive: Boolean = false) {
758+
fun setupStemActions() {
772759
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
773760
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
774761
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
775762
val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
776763

777-
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) ||
778-
isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive)
779-
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) ||
780-
isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive)
781-
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) ||
782-
isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive)
783-
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) ||
784-
isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive)
764+
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault) ||
765+
isCustomAction(config.rightSinglePressAction, singlePressDefault) ||
766+
(cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS)
767+
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault) ||
768+
isCustomAction(config.rightDoublePressAction, doublePressDefault)
769+
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault) ||
770+
isCustomAction(config.rightTriplePressAction, triplePressDefault)
771+
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault) ||
772+
isCustomAction(config.rightLongPressAction, longPressDefault) ||
773+
(cameraActive && config.cameraAction == StemPressType.LONG_PRESS)
785774
Log.d(TAG, "Setting up stem actions: " +
786775
"Single Press Customized: $singlePressCustomized, " +
787776
"Double Press Customized: $doublePressCustomized, " +
@@ -963,12 +952,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
963952
override fun onStemPressReceived(stemPress: ByteArray) {
964953
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
965954

966-
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud")
967-
968-
val action = getActionFor(bud, stemPressType)
969-
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
970-
971-
action?.let { executeStemAction(it) }
955+
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}")
956+
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
957+
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
958+
} else {
959+
val action = getActionFor(bud, stemPressType)
960+
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
961+
action?.let { executeStemAction(it) }
962+
}
972963
}
973964
override fun onAudioSourceReceived(audioSource: ByteArray) {
974965
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
@@ -1024,7 +1015,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
10241015
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
10251016
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
10261017
StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
1027-
StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
10281018
StemAction.DIGITAL_ASSISTANT -> {
10291019
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
10301020
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply {
@@ -1171,7 +1161,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
11711161
rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
11721162

11731163
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
1174-
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!
1164+
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!,
1165+
1166+
cameraAction = sharedPreferences.getString("camera_action", null)?.let { AACPManager.Companion.StemPressType.valueOf(it) },
11751167
)
11761168
}
11771169

@@ -1252,6 +1244,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
12521244
)!!
12531245
setupStemActions()
12541246
}
1247+
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }
12551248
}
12561249

12571250
if (key == "mac_address") {
@@ -1780,6 +1773,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
17801773
handleIncomingCallOnceConnected = false
17811774
}
17821775
}
1776+
17831777
}
17841778
}
17851779

android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
1818

19+
@file:OptIn(ExperimentalEncodingApi::class)
20+
1921
package me.kavishdevar.librepods.services
2022

2123

2224
import android.accessibilityservice.AccessibilityService
2325
import android.util.Log
2426
import android.view.accessibility.AccessibilityEvent
27+
import kotlin.io.encoding.ExperimentalEncodingApi
2528

2629
private const val TAG="AppListenerService"
2730

@@ -35,12 +38,28 @@ val cameraPackages = setOf(
3538
"com.nothing.camera"
3639
)
3740

41+
var cameraOpen = false
42+
3843
class AppListenerService : AccessibilityService() {
3944
override fun onAccessibilityEvent(ev: AccessibilityEvent?) {
4045
try {
4146
if (ev?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
4247
val pkg = ev.packageName?.toString() ?: return
43-
Log.d(TAG, "Opened: $pkg")
48+
if (pkg == "com.android.systemui") return // after camera opens, systemui is opened, probably for the privacy indicators
49+
Log.d(TAG, "Package: $pkg, cameraOpen: $cameraOpen")
50+
if (pkg in cameraPackages) {
51+
Log.d(TAG, "Camera app opened: $pkg")
52+
if (!cameraOpen) cameraOpen = true
53+
ServiceManager.getService()?.cameraOpened()
54+
} else {
55+
if (cameraOpen) {
56+
cameraOpen = false
57+
ServiceManager.getService()?.cameraClosed()
58+
} else {
59+
Log.d(TAG, "ignoring")
60+
}
61+
}
62+
// Log.d(TAG, "Opened: $pkg")
4463
}
4564
} catch(e: Exception) {
4665
Log.e(TAG, "Error in onAccessibilityEvent: ${e.message}")

android/app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,6 @@
177177
<string name="camera_remote">Camera Remote</string>
178178
<string name="camera_control">Camera Control</string>
179179
<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>
180+
<string name="app_listener_service_label">Camera listener</string>
181+
<string name="app_listener_service_description">Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods.</string>
180182
</resources>

0 commit comments

Comments
 (0)