From 5802a5cace13f774135801ab848c69fc759c8541 Mon Sep 17 00:00:00 2001 From: Aryan Patel Date: Wed, 17 Sep 2025 18:06:18 -0500 Subject: [PATCH 1/2] feat(camera): volume buttons act as shutter (only on camera screen, respects enabled state) Intercepts Volume Up/Down (and KEYCODE_CAMERA) to trigger captureImage(). Scoped to the camera screen only; otherwise volume works normally. Gated by the same shutter enabled flag as the UI button; ignores long-press repeats. Activity forwards keys via lightweight HardwareKeyManager; screen registers via DisposableEffect. CameraCaptureButton remains UI-only (no hardware-key code). --- .../developers/androidify/MainActivity.kt | 10 +++++ .../androidify/camera/CameraCaptureButton.kt | 40 ++++++++++++++++++- .../androidify/camera/CameraScreen.kt | 4 ++ .../androidify/camera/HardwareKeyManager.kt | 27 +++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt diff --git a/app/src/main/java/com/android/developers/androidify/MainActivity.kt b/app/src/main/java/com/android/developers/androidify/MainActivity.kt index 9d2e5e24..da4e9a37 100644 --- a/app/src/main/java/com/android/developers/androidify/MainActivity.kt +++ b/app/src/main/java/com/android/developers/androidify/MainActivity.kt @@ -17,6 +17,7 @@ package com.android.developers.androidify import android.os.Build import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import android.window.TrustedPresentationThresholds import androidx.activity.ComponentActivity @@ -28,6 +29,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import com.android.developers.androidify.camera.HardwareKeyManager import com.android.developers.androidify.navigation.MainNavigation import com.android.developers.androidify.theme.AndroidifyTheme import com.android.developers.androidify.util.LocalOcclusion @@ -94,4 +96,12 @@ class MainActivity : ComponentActivity() { windowManager.unregisterTrustedPresentationListener(presentationListener) } } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = + if (HardwareKeyManager.dispatchDown(keyCode, event)) true + else super.onKeyDown(keyCode, event) + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = + if (HardwareKeyManager.dispatchUp(keyCode, event)) true + else super.onKeyUp(keyCode, event) } diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt index c49bb074..d8d0a279 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt @@ -15,6 +15,7 @@ */ package com.android.developers.androidify.camera +import android.view.KeyEvent import androidx.compose.foundation.clickable import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource @@ -28,7 +29,10 @@ import androidx.compose.material3.ripple import androidx.compose.material3.toPath import androidx.compose.material3.toShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache @@ -86,7 +90,6 @@ internal fun CameraCaptureButton( } else { stringResource(R.string.capture_image_button_disabled_content_description) } - Spacer( modifier .indication(interactionSource, ScaleIndicationNodeFactory(animationSpec)) @@ -146,3 +149,38 @@ internal fun CameraCaptureButton( }, ) } + +@Composable +internal fun RegisterHardwareShutter( + enabled: Boolean, + onCapture: () -> Unit, +) { + val currentCapture by rememberUpdatedState(newValue = onCapture) + + DisposableEffect(enabled) { + val token = HardwareKeyManager.register(object : HardwareKeyManager.Handler { + override val priority = 10 + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (!enabled || event.repeatCount != 0) return false + val isVolumeOrCamera = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_CAMERA + if (!isVolumeOrCamera) return false + + currentCapture() + return true + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + val isVolumeOrCamera = + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_CAMERA + return enabled && isVolumeOrCamera + } + }) + onDispose { token.close() } + } +} diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt index 80b649fd..7c4a5f87 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt @@ -226,6 +226,10 @@ fun StatelessCameraPreviewContent( enabled = detectedPose, captureImageClicked = requestCaptureImage, ) + RegisterHardwareShutter( + enabled = detectedPose, + onCapture = requestCaptureImage, + ) }, flipCameraButton = { flipModifier -> if (canFlipCamera) { diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt new file mode 100644 index 00000000..37434832 --- /dev/null +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt @@ -0,0 +1,27 @@ +package com.android.developers.androidify.camera + +import android.view.KeyEvent +import java.util.concurrent.CopyOnWriteArrayList + +object HardwareKeyManager { + interface Handler { + /** Higher number = higher priority */ + val priority: Int get() = 0 + fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean = false + fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = false + } + + private val handlers = CopyOnWriteArrayList() + + fun register(handler: Handler): AutoCloseable { + handlers.add(handler) + handlers.sortByDescending { it.priority } + return AutoCloseable { handlers.remove(handler) } + } + + fun dispatchDown(keyCode: Int, event: KeyEvent): Boolean = + handlers.any { it.onKeyDown(keyCode, event) } + + fun dispatchUp(keyCode: Int, event: KeyEvent): Boolean = + handlers.any { it.onKeyUp(keyCode, event) } +} \ No newline at end of file From 0d397914b2a238e99b969d4985b2319efde0e3dc Mon Sep 17 00:00:00 2001 From: Aryan Patel Date: Wed, 17 Sep 2025 18:31:13 -0500 Subject: [PATCH 2/2] feat(camera): volume buttons act as shutter (only on camera screen, respects enabled state) Intercepts Volume Up/Down (and KEYCODE_CAMERA) to trigger captureImage(). Scoped to the camera screen only; otherwise volume works normally. Gated by the same shutter enabled flag as the UI button; ignores long-press repeats. Activity forwards keys via lightweight HardwareKeyManager; screen registers via DisposableEffect. CameraCaptureButton remains UI-only (no hardware-key code). Fixes #117 --- .../androidify/camera/CameraCaptureButton.kt | 30 ++++++++++--------- .../androidify/camera/HardwareKeyManager.kt | 26 +++++++++++----- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt index d8d0a279..9a4d78f2 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/CameraCaptureButton.kt @@ -161,25 +161,27 @@ internal fun RegisterHardwareShutter( val token = HardwareKeyManager.register(object : HardwareKeyManager.Handler { override val priority = 10 - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (!enabled || event.repeatCount != 0) return false - val isVolumeOrCamera = - keyCode == KeyEvent.KEYCODE_VOLUME_UP || - keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || - keyCode == KeyEvent.KEYCODE_CAMERA - if (!isVolumeOrCamera) return false + private fun isShutterKey(keyCode: Int): Boolean { + return when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEvent.KEYCODE_CAMERA -> true + else -> false + } + } - currentCapture() - return true + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (enabled && event.repeatCount == 0 && isShutterKey(keyCode)) { + currentCapture() + return true + } + return false } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { - val isVolumeOrCamera = - keyCode == KeyEvent.KEYCODE_VOLUME_UP || - keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || - keyCode == KeyEvent.KEYCODE_CAMERA - return enabled && isVolumeOrCamera + return enabled && isShutterKey(keyCode) } + }) onDispose { token.close() } } diff --git a/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt b/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt index 37434832..a1a0e80d 100644 --- a/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt +++ b/feature/camera/src/main/java/com/android/developers/androidify/camera/HardwareKeyManager.kt @@ -11,17 +11,27 @@ object HardwareKeyManager { fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean = false } - private val handlers = CopyOnWriteArrayList() + private val handlers = mutableListOf() fun register(handler: Handler): AutoCloseable { - handlers.add(handler) - handlers.sortByDescending { it.priority } - return AutoCloseable { handlers.remove(handler) } + synchronized(handlers) { + handlers.add(handler) + handlers.sortByDescending { it.priority } + } + return AutoCloseable { + synchronized(handlers) { + handlers.remove(handler) + } + } } - fun dispatchDown(keyCode: Int, event: KeyEvent): Boolean = - handlers.any { it.onKeyDown(keyCode, event) } + fun dispatchDown(keyCode: Int, event: KeyEvent): Boolean { + val handlersCopy = synchronized(handlers) { handlers.toList() } + return handlersCopy.any { it.onKeyDown(keyCode, event) } + } - fun dispatchUp(keyCode: Int, event: KeyEvent): Boolean = - handlers.any { it.onKeyUp(keyCode, event) } + fun dispatchUp(keyCode: Int, event: KeyEvent): Boolean { + val handlersCopy = synchronized(handlers) { handlers.toList() } + return handlersCopy.any { it.onKeyUp(keyCode, event) } + } } \ No newline at end of file