From d9dd75e1e123eba714082b0a53be34474abe4afa Mon Sep 17 00:00:00 2001 From: Joseph Kuriackal Date: Tue, 20 Oct 2026 23:48:04 +0530 Subject: [PATCH] feat: power button in Button Remap Button Remap now starts with a button picker (Volume up, Volume down, Power) above the Screen off / Screen on picker. Double press keeps Android's own camera gesture. An optional "Flip camera with double press" switches between front and back cameras while a camera app is open, using camera availability to tell whether a camera was in use at the first press. Press and hold is set per screen state: Digital assistant, Power menu or Custom actions. Matching system choices stay with Android; otherwise Essentials reads the power key through Shizuku or root and runs the choice itself. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/SettingsRepository.kt | 27 ++ .../essentials/domain/model/RemapSlot.kt | 38 +++ .../domain/registry/FeatureRegistry.kt | 12 + .../essentials/input/InputDeviceScanner.kt | 30 +++ .../essentials/input/InputEventModels.kt | 8 + .../essentials/input/InputEventReader.kt | 1 + .../essentials/input/PowerPressDetector.kt | 151 +++++++++++ .../services/InputEventListenerService.kt | 79 ++++++ .../services/handlers/ButtonRemapHandler.kt | 100 +++++++- .../tiles/ScreenOffAccessibilityService.kt | 105 +++++++- .../hardware/ButtonRemapSettingsUI.kt | 238 ++++++++++++++++-- .../essentials/utils/CameraFlipButton.kt | 110 ++++++++ .../essentials/utils/PowerButtonGestures.kt | 81 ++++++ .../essentials/viewmodels/MainViewModel.kt | 36 +++ app/src/main/res/values/strings.xml | 31 +++ .../input/InputDeviceScannerTest.kt | 48 ++++ .../input/PowerGestureTrackerTest.kt | 54 ++++ .../essentials/utils/CameraFlipButtonTest.kt | 80 ++++++ .../utils/PowerButtonGesturesTest.kt | 45 ++++ 19 files changed, 1237 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/input/PowerPressDetector.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/CameraFlipButton.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/PowerButtonGestures.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/input/InputDeviceScannerTest.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/input/PowerGestureTrackerTest.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/utils/CameraFlipButtonTest.kt create mode 100644 app/src/test/java/com/sameerasw/essentials/utils/PowerButtonGesturesTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 29a828101f..141e534d8c 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -33,6 +33,8 @@ import com.sameerasw.essentials.domain.model.NotificationLightingColorMode import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition +import com.sameerasw.essentials.domain.model.PowerChoice +import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile import com.sameerasw.essentials.domain.model.SystemShortcutsState import com.sameerasw.essentials.domain.model.TrackedRepo @@ -154,6 +156,25 @@ class SettingsRepository( } } + fun getPowerChoice(slot: RemapSlot): PowerChoice { + val defaultChoice = slot.defaultChoice ?: PowerChoice.ASSISTANT + val key = slot.choicePrefKey ?: return defaultChoice + val value = prefs.getString(key, null) ?: return defaultChoice + return try { + PowerChoice.valueOf(value) + } catch (_: IllegalArgumentException) { + defaultChoice + } + } + + fun setPowerChoice( + slot: RemapSlot, + choice: PowerChoice, + ) { + val key = slot.choicePrefKey ?: return + prefs.edit().putString(key, choice.name).apply() + } + companion object { const val PREFS_NAME = "essentials_prefs" @@ -249,6 +270,12 @@ class SettingsRepository( const val KEY_BUTTON_REMAP_VOL_DOWN_ACTION = "button_remap_vol_down_action" // Legacy const val KEY_BUTTON_REMAP_VOL_UP_ACTION_ON = "button_remap_vol_up_action_on" const val KEY_BUTTON_REMAP_VOL_DOWN_ACTION_ON = "button_remap_vol_down_action_on" + const val KEY_BUTTON_REMAP_POWER_HOLD_ACTION_OFF = "button_remap_power_hold_action_off" + const val KEY_BUTTON_REMAP_POWER_HOLD_ACTION_ON = "button_remap_power_hold_action_on" + const val KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_OFF = "button_remap_power_hold_choice_off" + const val KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_ON = "button_remap_power_hold_choice_on" + const val KEY_BUTTON_REMAP_POWER_OVERRIDDEN = "button_remap_power_overridden" + const val KEY_BUTTON_REMAP_POWER_CAMERA_FLIP = "button_remap_power_camera_flip" const val KEY_BUTTON_REMAP_HAPTIC_TYPE = "button_remap_haptic_type" const val KEY_FLASHLIGHT_HAPTIC_TYPE = "flashlight_haptic_type" // Legacy const val KEY_BUTTON_REMAP_MIGRATION_DONE = "button_remap_action_migration_done" diff --git a/app/src/main/java/com/sameerasw/essentials/domain/model/RemapSlot.kt b/app/src/main/java/com/sameerasw/essentials/domain/model/RemapSlot.kt index 2accbac8c1..8a2de7b209 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/model/RemapSlot.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/model/RemapSlot.kt @@ -15,6 +15,7 @@ import com.sameerasw.essentials.data.repository.SettingsRepository enum class RemapInput { VOLUME_UP, VOLUME_DOWN, + POWER_HOLD, } enum class RemapScreenState { @@ -22,6 +23,12 @@ enum class RemapScreenState { ON, } +enum class PowerChoice { + ASSISTANT, + POWER_MENU, + ACTIONS, +} + data class RemapSlot( val input: RemapInput, val screenState: RemapScreenState, @@ -42,6 +49,37 @@ data class RemapSlot( } else { SettingsRepository.KEY_BUTTON_REMAP_VOL_DOWN_ACTION_OFF } + + RemapInput.POWER_HOLD -> + if (screenState == RemapScreenState.ON) { + SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_ACTION_ON + } else { + SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_ACTION_OFF + } + } + + val choicePrefKey: String? + get() = + when (input) { + RemapInput.POWER_HOLD -> + if (screenState == RemapScreenState.ON) { + SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_ON + } else { + SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_OFF + } + + RemapInput.VOLUME_UP, + RemapInput.VOLUME_DOWN, + -> null + } + + val defaultChoice: PowerChoice? + get() = + when (input) { + RemapInput.POWER_HOLD -> PowerChoice.ASSISTANT + RemapInput.VOLUME_UP, + RemapInput.VOLUME_DOWN, + -> null } companion object { diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 49fcfa500c..19ee26d2b6 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -1806,6 +1806,18 @@ object FeatureRegistry { "flashlight_toggle", R.array.keywords_flashlight, ), + SearchSetting( + R.string.search_remap_power_camera_flip_title, + R.string.search_remap_power_camera_flip_desc, + "power_camera_flip", + R.array.keywords_power_camera_flip, + ), + SearchSetting( + R.string.search_remap_power_hold_title, + R.string.search_remap_power_hold_desc, + "power_hold", + R.array.keywords_power_hold, + ), SearchSetting( R.string.search_flashlight_pocket_title, R.string.search_flashlight_pocket_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/input/InputDeviceScanner.kt b/app/src/main/java/com/sameerasw/essentials/input/InputDeviceScanner.kt index 85fbf36e20..34a69bec75 100644 --- a/app/src/main/java/com/sameerasw/essentials/input/InputDeviceScanner.kt +++ b/app/src/main/java/com/sameerasw/essentials/input/InputDeviceScanner.kt @@ -9,7 +9,9 @@ package com.sameerasw.essentials.input +import android.content.Context import com.sameerasw.essentials.shizuku.ShizukuProcessHelper +import com.sameerasw.essentials.utils.ShellUtils import java.io.BufferedReader import java.io.InputStreamReader @@ -42,4 +44,32 @@ class InputDeviceScanner { } return devices } + + fun scanForPowerDevices(context: Context): List { + val output = ShellUtils.runCommandWithOutput(context, "getevent -pl", notifyOnError = false) + return if (output != null) parsePowerDevices(output) else emptyList() + } + + companion object { + fun parsePowerDevices(output: String): List { + val result = mutableListOf() + var currentPath: String? = null + var currentPathAdded = false + + output.lineSequence().forEach { rawLine -> + val line = rawLine.trim() + if (line.startsWith("add device")) { + currentPath = line.substringAfterLast(": ").trim() + currentPathAdded = false + } else if (currentPath != null && !currentPathAdded) { + val tokens = line.split(Regex("\\s+")) + if (tokens.contains("KEY_POWER")) { + result.add(currentPath!!) + currentPathAdded = true + } + } + } + return result + } + } } diff --git a/app/src/main/java/com/sameerasw/essentials/input/InputEventModels.kt b/app/src/main/java/com/sameerasw/essentials/input/InputEventModels.kt index c379cd3793..8ddbd85352 100644 --- a/app/src/main/java/com/sameerasw/essentials/input/InputEventModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/input/InputEventModels.kt @@ -39,3 +39,11 @@ sealed class VolumePressEvent { } enum class VolumeDirection { UP, DOWN } + +enum class PowerGesture { DOUBLE_PRESS, HOLD } + +data class PowerPressEvent( + val gesture: PowerGesture, + val screenOnBefore: Boolean, + val firstPressAt: Long = 0L, +) diff --git a/app/src/main/java/com/sameerasw/essentials/input/InputEventReader.kt b/app/src/main/java/com/sameerasw/essentials/input/InputEventReader.kt index 73f66c2da0..f334fe2e1e 100644 --- a/app/src/main/java/com/sameerasw/essentials/input/InputEventReader.kt +++ b/app/src/main/java/com/sameerasw/essentials/input/InputEventReader.kt @@ -21,6 +21,7 @@ class InputEventReader( const val EV_KEY = 1 const val KEY_VOLUMEUP = 115 const val KEY_VOLUMEDOWN = 114 + const val KEY_POWER = 116 } private var process: Process? = null diff --git a/app/src/main/java/com/sameerasw/essentials/input/PowerPressDetector.kt b/app/src/main/java/com/sameerasw/essentials/input/PowerPressDetector.kt new file mode 100644 index 0000000000..1193df5cc6 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/input/PowerPressDetector.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Input + * File: PowerPressDetector.kt + * Description: Component file for PowerPressDetector.kt. + */ + +package com.sameerasw.essentials.input + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +class PowerGestureTracker( + private val doublePressWindowMs: Long = 300, +) { + private var pendingDownMs: Long? = null + + fun onDown(timeMs: Long): Boolean { + val thatDown = pendingDownMs + return if (thatDown != null && timeMs - thatDown <= doublePressWindowMs) { + pendingDownMs = null + true + } else { + pendingDownMs = timeMs + false + } + } + + fun isGestureStart(timeMs: Long): Boolean { + val thatDown = pendingDownMs + return thatDown == null || timeMs - thatDown > doublePressWindowMs + } + + fun onHold() { + pendingDownMs = null + } +} + +class PowerPressDetector( + private val devicePath: String, + private val isScreenOn: () -> Boolean, + private val holdThresholdMs: Long = 500, +) { + private val _events = MutableSharedFlow() + val events = _events.asSharedFlow() + + private val isRunning = AtomicBoolean(false) + val isListening: Boolean + get() = isRunning.get() + + private var detectionJob: Job? = null + private var holdJob: Job? = null + private val reader = InputEventReader(devicePath) + private val tracker = PowerGestureTracker() + private var gestureScreenOn = false + private var gestureFirstPressAt = 0L + private var pendingDouble = false + private var listeningScope: CoroutineScope? = null + + fun startListening(scope: CoroutineScope): Boolean { + if (isRunning.getAndSet(true)) return false + listeningScope = scope + + detectionJob = + scope.launch(Dispatchers.IO) { + if (!reader.open()) { + isRunning.set(false) + return@launch + } + + try { + while (isRunning.get() && isActive) { + val event = reader.readEvent() + if (event == null) { + delay(20) + continue + } + + if (event.type != InputEventReader.EV_KEY) continue + if (event.code != InputEventReader.KEY_POWER) continue + + when (event.value) { + 1 -> { + val t = event.timeSecond * 1000 + event.timeMicro / 1000 + if (tracker.isGestureStart(t)) { + gestureScreenOn = isScreenOn() + gestureFirstPressAt = SystemClock.elapsedRealtime() + } + if (tracker.onDown(t)) { + holdJob?.cancel() + pendingDouble = true + } else { + holdJob?.cancel() + holdJob = + listeningScope?.launch(Dispatchers.IO) { + delay(holdThresholdMs) + tracker.onHold() + _events.emit( + PowerPressEvent( + PowerGesture.HOLD, + gestureScreenOn, + gestureFirstPressAt, + ), + ) + } + } + } + + 0 -> { + holdJob?.cancel() + if (pendingDouble) { + pendingDouble = false + // Android toggles screen on release; emit after release so handler acts after it + _events.emit( + PowerPressEvent( + PowerGesture.DOUBLE_PRESS, + gestureScreenOn, + gestureFirstPressAt, + ), + ) + } + } + } + } + } catch (_: Exception) { + } finally { + holdJob?.cancel() + reader.close() + isRunning.set(false) + } + } + return true + } + + fun stopListening() { + isRunning.set(false) + detectionJob?.cancel() + holdJob?.cancel() + reader.close() + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/services/InputEventListenerService.kt b/app/src/main/java/com/sameerasw/essentials/services/InputEventListenerService.kt index 8bc8dc9787..2603450f9d 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/InputEventListenerService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/InputEventListenerService.kt @@ -10,16 +10,21 @@ package com.sameerasw.essentials.services import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.hardware.display.DisplayManager import android.os.Build import android.os.IBinder +import android.os.PowerManager import android.util.Log import android.view.Display import androidx.core.app.NotificationCompat import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.input.InputDeviceScanner +import com.sameerasw.essentials.input.PowerPressDetector import com.sameerasw.essentials.input.VolumeLongPressDetector import com.sameerasw.essentials.input.VolumePressEvent import com.sameerasw.essentials.shizuku.ShizukuPermissionHelper @@ -38,6 +43,10 @@ class InputEventListenerService : Service() { const val EXTRA_DIRECTION = "direction" const val EXTRA_DURATION_MS = "duration_ms" const val ACTION_STOP = "com.sameerasw.essentials.STOP_INPUT_LISTENER" + const val ACTION_POWER_GESTURE = "com.sameerasw.essentials.POWER_GESTURE" + const val EXTRA_POWER_GESTURE = "power_gesture" + const val EXTRA_SCREEN_ON_BEFORE = "screen_on_before" + const val EXTRA_FIRST_PRESS_AT = "first_press_at" private const val NOTIFICATION_ID = 4242 } @@ -59,9 +68,25 @@ class InputEventListenerService : Service() { private var scope: CoroutineScope? = null private var detector: VolumeLongPressDetector? = null + private val powerDetectors = mutableListOf() + + @Volatile private var screenOn = true private lateinit var shizukuHelper: ShizukuPermissionHelper private var isTorchOn = false + private val screenReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + when (intent?.action) { + Intent.ACTION_SCREEN_ON -> screenOn = true + Intent.ACTION_SCREEN_OFF -> screenOn = false + } + } + } + private val binderReceivedListener = Shizuku.OnBinderReceivedListener { Log.d("InputEventListener", "Shizuku binder received, attempting to start listening") @@ -118,6 +143,20 @@ class InputEventListenerService : Service() { ) } + val powerManager = getSystemService(POWER_SERVICE) as? PowerManager + screenOn = powerManager?.isInteractive ?: true + + val filter = + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(screenReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(screenReceiver, filter) + } + Shizuku.addBinderReceivedListener(binderReceivedListener) Shizuku.addBinderDeadListener(binderDeadListener) @@ -272,12 +311,48 @@ class InputEventListenerService : Service() { } activeDetector.startListening(this) + + val powerPaths = + withContext(Dispatchers.IO) { + InputDeviceScanner().scanForPowerDevices(this@InputEventListenerService) + } + + powerPaths.forEach { path -> + val pDetector = + PowerPressDetector( + path, + { screenOn }, + ) + synchronized(powerDetectors) { + powerDetectors.add(pDetector) + } + launch { + pDetector.events.collect { event -> + sendBroadcast( + Intent(ACTION_POWER_GESTURE).apply { + putExtra(EXTRA_POWER_GESTURE, event.gesture.name) + putExtra(EXTRA_SCREEN_ON_BEFORE, event.screenOnBefore) + putExtra(EXTRA_FIRST_PRESS_AT, event.firstPressAt) + setPackage(packageName) + }, + ) + } + } + pDetector.startListening(this) + } } } private fun stopListening() { detector?.stopListening() detector = null + val toStop = + synchronized(powerDetectors) { + val list = powerDetectors.toList() + powerDetectors.clear() + list + } + toStop.forEach { it.stopListening() } } override fun onStartCommand( @@ -303,6 +378,10 @@ class InputEventListenerService : Service() { } override fun onDestroy() { + try { + unregisterReceiver(screenReceiver) + } catch (_: Exception) { + } Shizuku.removeBinderReceivedListener(binderReceivedListener) Shizuku.removeBinderDeadListener(binderDeadListener) stopListening() diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt index c8b6bcba06..563b679326 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/ButtonRemapHandler.kt @@ -25,21 +25,32 @@ import android.view.KeyEvent import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.HapticFeedbackType import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.model.PowerChoice +import com.sameerasw.essentials.domain.model.RemapInput +import com.sameerasw.essentials.domain.model.RemapScreenState import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.services.InputEventListenerService import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor +import com.sameerasw.essentials.utils.CameraFlipButton +import com.sameerasw.essentials.utils.CameraSession +import com.sameerasw.essentials.utils.PowerButtonGestures +import com.sameerasw.essentials.utils.ShellUtils +import com.sameerasw.essentials.utils.lensInUseAt +import com.sameerasw.essentials.utils.openLens import com.sameerasw.essentials.utils.performHapticFeedback import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ButtonRemapHandler( private val service: AccessibilityService, private val flashlightHandler: FlashlightHandler, private val volumeStepsHandler: VolumeStepsHandler, + private val cameraSessions: () -> List = { emptyList() }, ) { private val settingsRepository = SettingsRepository(service) private val handler = Handler(Looper.getMainLooper()) @@ -49,6 +60,9 @@ class ButtonRemapHandler( private var lastPendingActions: List = emptyList() private var sequenceJob: Job? = null private val longPressTimeout = 500L + private val powerSettleMs = 250L + private val flipAttempts = 10 + private val flipRetryMs = 300L var isVolumeDialogVisible: Boolean = false @@ -86,19 +100,14 @@ class ButtonRemapHandler( val isAod = isAodShowing() val shellReady = - com.sameerasw.essentials.utils.ShellUtils - .isAvailable(service) && - com.sameerasw.essentials.utils.ShellUtils.hasPermission( - service, - ) + ShellUtils.isAvailable(service) && + ShellUtils.hasPermission(service) val devicePathDetected = !prefs.getString("shizuku_detected_device_path", null).isNullOrEmpty() val useShell = isButtonRemapUseShizuku || - com.sameerasw.essentials.utils.ShellUtils.isRootEnabled( - service, - ) + ShellUtils.isRootEnabled(service) if (useShell && isButtonRemapEnabled && shellReady && devicePathDetected && !isScreenInteractive && !isAod) { val isTorchControl = @@ -206,6 +215,78 @@ class ButtonRemapHandler( } } + fun handlePowerGesture(intent: Intent) { + val prefs = service.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) + if (!prefs.getBoolean("button_remap_enabled", false)) return + + val gestureName = intent.getStringExtra(InputEventListenerService.EXTRA_POWER_GESTURE) + val screenOn = intent.getBooleanExtra(InputEventListenerService.EXTRA_SCREEN_ON_BEFORE, true) + + when (gestureName) { + "DOUBLE_PRESS" -> { + val firstPressAt = intent.getLongExtra(InputEventListenerService.EXTRA_FIRST_PRESS_AT, 0L) + val lensBefore = if (firstPressAt > 0) lensInUseAt(cameraSessions(), firstPressAt) else null + val flip = + screenOn && + settingsRepository.getBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP, false) && + lensBefore != null + if (flip) { + scope.launch { + delay(powerSettleMs) + flipCamera(lensBefore) + } + } + } + "HOLD" -> { + if (!PowerButtonGestures.isHoldHandled(settingsRepository)) return + val slot = RemapSlot(RemapInput.POWER_HOLD, if (screenOn) RemapScreenState.ON else RemapScreenState.OFF) + val choice = settingsRepository.getPowerChoice(slot) + scope.launch { + delay(powerSettleMs) + when (choice) { + PowerChoice.ASSISTANT -> { + sendKey("KEYCODE_WAKEUP") + sendKey("KEYCODE_ASSIST") + } + PowerChoice.POWER_MENU -> { + sendKey("KEYCODE_WAKEUP") + service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_POWER_DIALOG) + } + PowerChoice.ACTIONS -> { + sendKey(if (screenOn) "KEYCODE_WAKEUP" else "KEYCODE_SLEEP") + handleLongPress(settingsRepository.getRemapActions(slot.prefKey)) + } + } + } + } + } + } + + private suspend fun flipCamera(lensBefore: Int) { + var attemptsLeft = flipAttempts + var openLens: Int? = null + while (attemptsLeft > 0) { + delay(flipRetryMs) + attemptsLeft-- + openLens = openLens(cameraSessions()) + if (openLens != null) break + } + if (openLens == null || openLens != lensBefore) return + + if (CameraFlipButton.click(service)) return + while (attemptsLeft > 0) { + delay(flipRetryMs) + attemptsLeft-- + if (CameraFlipButton.click(service)) return + } + } + + private suspend fun sendKey(keyCode: String) { + withContext(Dispatchers.IO) { + ShellUtils.runCommand(service, "input keyevent $keyCode", notifyOnError = false) + } + } + private fun isFlashlightMapped(keyCode: Int): Boolean = listOf(true, false).any { isScreenOn -> RemapSlot @@ -274,5 +355,4 @@ class ButtonRemapHandler( } catch (_: Exception) { false } - } diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index ea0d907beb..a6052e51b0 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -22,10 +22,13 @@ import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager import android.media.session.MediaSessionManager import android.os.Handler import android.os.HandlerThread import android.os.Looper +import android.os.SystemClock import android.os.Vibrator import android.view.KeyEvent import android.view.accessibility.AccessibilityEvent @@ -55,7 +58,9 @@ import com.sameerasw.essentials.services.handlers.LockscreenShortcutsHandler import com.sameerasw.essentials.services.handlers.StatusGlanceHandler import com.sameerasw.essentials.services.handlers.VolumeStepsHandler import com.sameerasw.essentials.services.receivers.FlashlightActionReceiver +import com.sameerasw.essentials.utils.CameraSession import com.sameerasw.essentials.utils.FreezeManager +import com.sameerasw.essentials.utils.PowerButtonGestures import com.sameerasw.essentials.utils.VolumePanelDetector import com.sameerasw.essentials.utils.media.AppVolumeController import com.sameerasw.essentials.utils.performHapticFeedback @@ -107,6 +112,60 @@ class ScreenOffAccessibilityService : private var isLightSensorRegistered = false private var isProximityRegisteredForPocket = false private var isProximityRegistered = false + private var isCameraCallbackRegistered = false + + @Volatile private var cameraSessions: List = emptyList() + private val cameraIdToLens = mutableMapOf() + + private val cameraAvailabilityCallback = + object : CameraManager.AvailabilityCallback() { + override fun onCameraUnavailable(cameraId: String) { + val facing = + try { + val cameraManager = getSystemService(CameraManager::class.java) + cameraManager?.getCameraCharacteristics(cameraId)?.get(CameraCharacteristics.LENS_FACING) + } catch (_: Exception) { + null + } + if (facing != null) { + cameraIdToLens[cameraId] = facing + val session = CameraSession(lens = facing, openedAt = SystemClock.elapsedRealtime()) + cameraSessions = (cameraSessions + session).takeLast(3) + } + } + + override fun onCameraAvailable(cameraId: String) { + val facing = cameraIdToLens[cameraId] ?: return + val now = SystemClock.elapsedRealtime() + val index = cameraSessions.indexOfLast { it.lens == facing && it.closedAt == null } + if (index != -1) { + val updated = cameraSessions.toMutableList() + updated[index] = updated[index].copy(closedAt = now) + cameraSessions = updated.toList() + } + } + } + + private fun updateCameraLensTracking() { + val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) + val shouldTrack = prefs.getBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP, false) + val cameraManager = getSystemService(CameraManager::class.java) ?: return + if (shouldTrack && !isCameraCallbackRegistered) { + try { + cameraManager.registerAvailabilityCallback(cameraAvailabilityCallback, uiHandler) + isCameraCallbackRegistered = true + } catch (_: Exception) { + } + } else if (!shouldTrack && isCameraCallbackRegistered) { + try { + cameraManager.unregisterAvailabilityCallback(cameraAvailabilityCallback) + } catch (_: Exception) { + } + isCameraCallbackRegistered = false + cameraSessions = emptyList() + cameraIdToLens.clear() + } + } private fun updatePocketModeExcludedAppsSet() { val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) @@ -316,6 +375,14 @@ class ScreenOffAccessibilityService : key == SettingsRepository.KEY_APP_VOLUME_LEVELS ) { AppVolumeController.updateState(this) + } else if (key == "button_remap_enabled" || + key == "button_remap_use_shizuku" || + key == SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_OFF || + key == SettingsRepository.KEY_BUTTON_REMAP_POWER_HOLD_CHOICE_ON || + key == SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP + ) { + updateCameraLensTracking() + updateInputEventListener() } } @@ -327,7 +394,13 @@ class ScreenOffAccessibilityService : flashlightHandler = FlashlightHandler(this, serviceScope) notificationLightingHandler = NotificationLightingHandler(this) volumeStepsHandler = VolumeStepsHandler(this) - buttonRemapHandler = ButtonRemapHandler(this, flashlightHandler, volumeStepsHandler) + buttonRemapHandler = + ButtonRemapHandler( + this, + flashlightHandler, + volumeStepsHandler, + cameraSessions = { cameraSessions }, + ) appFlowHandler = AppFlowHandler(this, this) ambientGlanceHandler = AmbientGlanceHandler(this) aodForceTurnOffHandler = AodForceTurnOffHandler(this) @@ -385,7 +458,7 @@ class ScreenOffAccessibilityService : lockscreenShortcutsHandler.onScreenOn() islandOverlayHandler.updateState() freezeHandler.removeCallbacks(freezeRunnable) - stopInputEventListener() + updateInputEventListener() updateOmniOverlay() updatePocketModeSensors() } @@ -401,7 +474,7 @@ class ScreenOffAccessibilityService : appFlowHandler.clearAuthenticated() appFlowHandler.clearConsciousGate() scheduleFreeze() - startInputEventListenerIfEnabled() + updateInputEventListener() ambientGlanceHandler.checkAndShowOnScreenOff() aodWallpaperOverlayHandler.onScreenOff() duoOverlayHandler.onScreenOff() @@ -435,6 +508,10 @@ class ScreenOffAccessibilityService : buttonRemapHandler.handleExternalVolumeLongPress(intent) } + InputEventListenerService.ACTION_POWER_GESTURE -> { + buttonRemapHandler.handlePowerGesture(intent) + } + "SHOW_AMBIENT_GLANCE", "HIDE_AMBIENT_GLANCE_TEMPORARILY", -> { @@ -472,6 +549,7 @@ class ScreenOffAccessibilityService : addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_USER_PRESENT) addAction(InputEventListenerService.ACTION_VOLUME_LONG_PRESSED) + addAction(InputEventListenerService.ACTION_POWER_GESTURE) addAction("SHOW_AMBIENT_GLANCE") addAction("HIDE_AMBIENT_GLANCE_TEMPORARILY") addAction("FORCE_TURN_OFF_AOD") @@ -493,6 +571,8 @@ class ScreenOffAccessibilityService : updatePocketModeExcludedAppsSet() updatePocketModeSensors() + updateInputEventListener() + updateCameraLensTracking() } private fun scheduleFreeze() { @@ -591,6 +671,15 @@ class ScreenOffAccessibilityService : } isLightSensorRegistered = false } + if (isCameraCallbackRegistered) { + try { + getSystemService(CameraManager::class.java)?.unregisterAvailabilityCallback(cameraAvailabilityCallback) + } catch (_: Exception) { + } + isCameraCallbackRegistered = false + cameraSessions = emptyList() + cameraIdToLens.clear() + } windowHandler.removeCallbacksAndMessages(null) windowThread.quitSafely() serviceScope.cancel() @@ -1036,6 +1125,16 @@ class ScreenOffAccessibilityService : private var isInputEventListenerRequested = false + private fun updateInputEventListener() { + val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) + val isEnabled = prefs.getBoolean("button_remap_enabled", false) + val useShizuku = prefs.getBoolean("button_remap_use_shizuku", false) + val needed = + isEnabled && useShizuku && + (!isScreenOn || PowerButtonGestures.isListenerNeeded(SettingsRepository(this))) + if (needed) startInputEventListenerIfEnabled() else stopInputEventListener() + } + private fun startInputEventListenerIfEnabled() { val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) val isEnabled = prefs.getBoolean("button_remap_enabled", false) diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ButtonRemapSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ButtonRemapSettingsUI.kt index d001e6328e..35c9710af2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ButtonRemapSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/hardware/ButtonRemapSettingsUI.kt @@ -9,6 +9,9 @@ package com.sameerasw.essentials.ui.features.system +import android.content.ActivityNotFoundException +import android.content.Intent +import android.provider.Settings import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -50,6 +53,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.sameerasw.essentials.R import com.sameerasw.essentials.domain.HapticFeedbackType import com.sameerasw.essentials.domain.diy.Action +import com.sameerasw.essentials.domain.model.PowerChoice import com.sameerasw.essentials.domain.model.RemapInput import com.sameerasw.essentials.domain.model.RemapScreenState import com.sameerasw.essentials.domain.model.RemapSlot @@ -59,8 +63,11 @@ import com.sameerasw.essentials.ui.core.cards.IconToggleItem import com.sameerasw.essentials.ui.core.containers.RoundedCardContainer import com.sameerasw.essentials.ui.core.pickers.HapticFeedbackPicker import com.sameerasw.essentials.ui.core.pickers.SegmentedPicker +import com.sameerasw.essentials.ui.core.sheets.PermissionsBottomSheet import com.sameerasw.essentials.ui.modifiers.highlight import com.sameerasw.essentials.utils.HapticUtil +import com.sameerasw.essentials.utils.PermissionUIHelper +import com.sameerasw.essentials.utils.PowerButtonGestures import com.sameerasw.essentials.viewmodels.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -73,7 +80,18 @@ fun ButtonRemapSettingsUI( val context = LocalContext.current val view = LocalView.current var selectedScreenTab by remember { mutableIntStateOf(0) } // 0: Off, 1: On - var selectedButtonTab by remember { mutableIntStateOf(0) } // 0: Up, 1: Down + var selectedButtonTab by remember { + mutableIntStateOf( + if (highlightSetting == "power_camera_flip" || + highlightSetting == "power_hold" + ) { + 2 + } else { + 0 + }, + ) + } // 0: Up, 1: Down, 2: Power + var requestingPermissionsFor by remember { mutableStateOf>?>(null) } val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current var shizukuStatus by remember { mutableStateOf(ShizukuStatus.NOT_RUNNING) } @@ -93,9 +111,25 @@ fun ButtonRemapSettingsUI( } } + requestingPermissionsFor?.let { (titleRes, permKeys) -> + PermissionsBottomSheet( + onDismissRequest = { + requestingPermissionsFor = null + viewModel.check(context) + }, + featureTitle = stringResource(titleRes), + permissions = PermissionUIHelper.getPermissionItems(permKeys, context, viewModel), + ) + } + val currentSlot = RemapSlot( - input = if (selectedButtonTab == 0) RemapInput.VOLUME_UP else RemapInput.VOLUME_DOWN, + input = + when (selectedButtonTab) { + 0 -> RemapInput.VOLUME_UP + 1 -> RemapInput.VOLUME_DOWN + else -> RemapInput.POWER_HOLD + }, screenState = if (selectedScreenTab == 0) RemapScreenState.OFF else RemapScreenState.ON, ) val currentActions: List = viewModel.remapActions[currentSlot].orEmpty() @@ -297,7 +331,7 @@ fun ButtonRemapSettingsUI( } Text( - text = stringResource(R.string.button_remap_section_long_press), + text = stringResource(R.string.button_remap_section_button), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(start = 16.dp), color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -305,44 +339,97 @@ fun ButtonRemapSettingsUI( // Button Picker & Tabs RoundedCardContainer(spacing = 2.dp) { - val screenOptions = + val buttonOptions = listOf( - stringResource(R.string.screen_off), - stringResource(R.string.screen_on), + stringResource(R.string.volume_up), + stringResource(R.string.volume_down), + stringResource(R.string.button_remap_power), ) SegmentedPicker( - items = screenOptions, - selectedItem = if (selectedScreenTab == 0) screenOptions[0] else screenOptions[1], + items = buttonOptions, + selectedItem = buttonOptions[selectedButtonTab], onItemSelected = { HapticUtil.performUIHaptic(view) - selectedScreenTab = screenOptions.indexOf(it) + selectedButtonTab = buttonOptions.indexOf(it) }, labelProvider = { it }, ) - val buttonOptions = + val screenOptions = listOf( - stringResource(R.string.volume_up), - stringResource(R.string.volume_down), + stringResource(R.string.screen_off), + stringResource(R.string.screen_on), ) SegmentedPicker( - items = buttonOptions, - selectedItem = if (selectedButtonTab == 0) buttonOptions[0] else buttonOptions[1], + items = screenOptions, + selectedItem = screenOptions[selectedScreenTab], onItemSelected = { HapticUtil.performUIHaptic(view) - selectedButtonTab = buttonOptions.indexOf(it) + selectedScreenTab = screenOptions.indexOf(it) }, labelProvider = { it }, ) } - ActionSequenceEditor( - viewModel = viewModel, - actions = currentActions, - onActionsChange = { viewModel.setRemapActions(currentSlot, it) }, - screenOnOnly = selectedScreenTab == 1, - listKey = currentSlot, - emptyText = stringResource(R.string.button_remap_no_actions), - ) + if (selectedButtonTab != 2) { + Text( + text = stringResource(R.string.button_remap_section_long_press), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ActionSequenceEditor( + viewModel = viewModel, + actions = currentActions, + onActionsChange = { viewModel.setRemapActions(currentSlot, it) }, + screenOnOnly = selectedScreenTab == 1, + listKey = currentSlot, + emptyText = stringResource(R.string.button_remap_no_actions), + ) + } else { + val screenState = + if (selectedScreenTab == 0) RemapScreenState.OFF else RemapScreenState.ON + Text( + text = stringResource(R.string.button_remap_double_press), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp) { + IconToggleItem( + iconRes = R.drawable.rounded_photo_camera_24, + title = stringResource(R.string.button_remap_power_camera_flip_title), + description = + stringResource( + R.string.button_remap_power_camera_flip_desc, + stringResource(R.string.button_remap_use_shizuku_title), + ), + isChecked = viewModel.isPowerCameraFlipEnabled.value, + onCheckedChange = { enabled -> + viewModel.setPowerCameraFlipEnabled(enabled) + }, + modifier = Modifier.highlight(highlightSetting == "power_camera_flip"), + ) + } + + PowerGestureSection( + slot = RemapSlot(RemapInput.POWER_HOLD, screenState), + titleRes = R.string.button_remap_press_and_hold, + options = + listOf( + PowerChoice.ASSISTANT, + PowerChoice.POWER_MENU, + PowerChoice.ACTIONS, + ), + viewModel = viewModel, + onRequestPermission = { + requestingPermissionsFor = + Pair(R.string.button_remap_press_and_hold, listOf("WRITE_SECURE_SETTINGS")) + }, + modifier = Modifier.highlight(highlightSetting == "power_hold"), + ) + } } } @@ -350,7 +437,9 @@ fun ButtonRemapSettingsUI( RoundedCardContainer { Text( text = - if (selectedScreenTab == 0) { + if (selectedButtonTab == 2) { + stringResource(R.string.button_remap_power_hint) + } else if (selectedScreenTab == 0) { stringResource(R.string.button_remap_screen_off_hint) } else { stringResource(R.string.button_remap_screen_on_hint) @@ -425,3 +514,104 @@ fun RemapActionItem( } } +@Composable +private fun PowerGestureSection( + slot: RemapSlot, + titleRes: Int, + options: List, + viewModel: MainViewModel, + onRequestPermission: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val offSlot = RemapSlot(slot.input, RemapScreenState.OFF) + val onSlot = RemapSlot(slot.input, RemapScreenState.ON) + val offChoice = viewModel.powerChoices[offSlot] ?: offSlot.defaultChoice ?: PowerChoice.ASSISTANT + val onChoice = viewModel.powerChoices[onSlot] ?: onSlot.defaultChoice ?: PowerChoice.ASSISTANT + val selectedChoice = viewModel.powerChoices[slot] ?: slot.defaultChoice ?: PowerChoice.ASSISTANT + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + RoundedCardContainer(spacing = 2.dp) { + options.forEach { option -> + val iconRes = + when (option) { + PowerChoice.ASSISTANT -> R.drawable.rounded_mic_24 + PowerChoice.POWER_MENU -> R.drawable.rounded_power_settings_new_24 + PowerChoice.ACTIONS -> R.drawable.rounded_switch_access_3_24 + } + val title = + if (option == slot.defaultChoice) { + stringResource( + R.string.button_remap_default_suffix, + stringResource(getPowerChoiceTitleRes(option)), + ) + } else { + stringResource(getPowerChoiceTitleRes(option)) + } + + RemapActionItem( + title = title, + iconRes = iconRes, + isSelected = selectedChoice == option, + onClick = { + if (option != slot.defaultChoice && !viewModel.canWriteSecureSettings(context)) { + onRequestPermission() + } else { + viewModel.setPowerChoice(slot, option, context) + } + }, + hasSettings = option == PowerChoice.ASSISTANT, + onSettingsClick = { + try { + context.startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) + } catch (_: ActivityNotFoundException) { + } + }, + ) + } + } + + if (selectedChoice == PowerChoice.ACTIONS) { + ActionSequenceEditor( + viewModel = viewModel, + actions = viewModel.remapActions[slot].orEmpty(), + onActionsChange = { viewModel.setRemapActions(slot, it) }, + screenOnOnly = slot.screenState == RemapScreenState.ON, + listKey = slot, + emptyText = stringResource(R.string.button_remap_power_no_actions), + ) + } + + if (PowerButtonGestures.isHandledByEssentials(offChoice, onChoice)) { + RoundedCardContainer { + Text( + text = + stringResource( + R.string.button_remap_power_hold_handled_hint, + stringResource(R.string.button_remap_use_shizuku_title), + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun getPowerChoiceTitleRes(choice: PowerChoice): Int = + when (choice) { + PowerChoice.ASSISTANT -> R.string.button_remap_power_assistant + PowerChoice.POWER_MENU -> R.string.button_remap_power_menu + PowerChoice.ACTIONS -> R.string.button_remap_power_actions + } diff --git a/app/src/main/java/com/sameerasw/essentials/utils/CameraFlipButton.kt b/app/src/main/java/com/sameerasw/essentials/utils/CameraFlipButton.kt new file mode 100644 index 0000000000..6288a123dd --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/CameraFlipButton.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Core Utilities + * File: CameraFlipButton.kt + * Description: Helpers for finding and clicking camera flip buttons via accessibility. + */ + +package com.sameerasw.essentials.utils + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo + +data class CameraSession( + val lens: Int, + val openedAt: Long, + val closedAt: Long? = null, +) + +fun lensInUseAt( + sessions: List, + time: Long, +): Int? = sessions.lastOrNull { it.openedAt < time && (it.closedAt == null || it.closedAt >= time) }?.lens + +fun openLens(sessions: List): Int? = sessions.lastOrNull { it.closedAt == null }?.lens + +object CameraFlipButton { + private val ID_KEYWORDS = + listOf( + "camera_switch", + "switch_camera", + "camera_toggle", + "toggle_camera", + "flip_camera", + "camera_flip", + "front_back", + "camera_facing", + ) + + private val DESCRIPTION_KEYWORDS = + listOf( + "switch camera", + "flip camera", + "switch to front", + "switch to back", + "switch to rear", + "front camera", + "back camera", + "rear camera", + "selfie camera", + ) + + fun isFlipButton( + viewId: String?, + description: CharSequence?, + ): Boolean { + if (!viewId.isNullOrBlank()) { + val entryName = viewId.substringAfter(":id/").lowercase() + if (ID_KEYWORDS.any { entryName.contains(it) }) { + return true + } + } + if (!description.isNullOrBlank()) { + val desc = description.toString().lowercase() + if (DESCRIPTION_KEYWORDS.any { desc.contains(it) }) { + return true + } + } + return false + } + + fun click(service: AccessibilityService): Boolean { + val appRoots = + service.windows + ?.filter { it.type == AccessibilityWindowInfo.TYPE_APPLICATION } + ?.mapNotNull { it.root } + .orEmpty() + val roots = + if (appRoots.isNotEmpty()) { + appRoots + } else { + listOfNotNull(service.rootInActiveWindow) + } + if (roots.isEmpty()) return false + + for (root in roots) { + val queue = ArrayDeque() + queue.add(root) + try { + while (queue.isNotEmpty()) { + val node = queue.removeFirst() + if (node.isVisibleToUser && isFlipButton(node.viewIdResourceName, node.contentDescription)) { + var target: AccessibilityNodeInfo? = node + while (target != null && !target.isClickable) { + target = target.parent + } + return target?.performAction(AccessibilityNodeInfo.ACTION_CLICK) ?: false + } + for (i in 0 until node.childCount) { + node.getChild(i)?.let(queue::add) + } + } + } catch (_: IllegalStateException) { + } + } + return false + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/utils/PowerButtonGestures.kt b/app/src/main/java/com/sameerasw/essentials/utils/PowerButtonGestures.kt new file mode 100644 index 0000000000..b9fb4d6309 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/utils/PowerButtonGestures.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 sameerasw.com + * License: MIT License + * + * Feature Module: Core Utilities + * File: PowerButtonGestures.kt + * Description: Helpers for synchronizing power button gestures with Android system settings. + */ + +package com.sameerasw.essentials.utils + +import android.content.Context +import android.provider.Settings +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.domain.model.PowerChoice +import com.sameerasw.essentials.domain.model.RemapInput +import com.sameerasw.essentials.domain.model.RemapScreenState +import com.sameerasw.essentials.domain.model.RemapSlot + +object PowerButtonGestures { + private const val SETTING_POWER_BUTTON_LONG_PRESS = "power_button_long_press" + + fun longPressValue( + off: PowerChoice, + on: PowerChoice, + ): Int = + if (off == PowerChoice.ASSISTANT && on == PowerChoice.ASSISTANT) { + 5 + } else if (off == PowerChoice.POWER_MENU && on == PowerChoice.POWER_MENU) { + 1 + } else { + 0 + } + + fun isHandledByEssentials( + off: PowerChoice, + on: PowerChoice, + ): Boolean = off != on || off == PowerChoice.ACTIONS || on == PowerChoice.ACTIONS + + fun isHoldHandled(repo: SettingsRepository): Boolean = + isHandledByEssentials( + repo.getPowerChoice(RemapSlot(RemapInput.POWER_HOLD, RemapScreenState.OFF)), + repo.getPowerChoice(RemapSlot(RemapInput.POWER_HOLD, RemapScreenState.ON)), + ) + + fun isListenerNeeded(repo: SettingsRepository): Boolean = + isHoldHandled(repo) || repo.getBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP, false) + + fun sync( + context: Context, + repo: SettingsRepository, + ) { + if (!PermissionUtils.canWriteSecureSettings(context)) return + + val holdOff = repo.getPowerChoice(RemapSlot(RemapInput.POWER_HOLD, RemapScreenState.OFF)) + val holdOn = repo.getPowerChoice(RemapSlot(RemapInput.POWER_HOLD, RemapScreenState.ON)) + val holdVal = longPressValue(holdOff, holdOn) + + try { + val cr = context.contentResolver + Settings.Global.putInt(cr, SETTING_POWER_BUTTON_LONG_PRESS, holdVal) + repo.putBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_OVERRIDDEN, true) + } catch (_: SecurityException) { + } + } + + fun restoreDefaults( + context: Context, + repo: SettingsRepository, + ) { + if (!repo.getBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_OVERRIDDEN, false)) return + if (!PermissionUtils.canWriteSecureSettings(context)) return + + try { + val cr = context.contentResolver + Settings.Global.putInt(cr, SETTING_POWER_BUTTON_LONG_PRESS, 5) + repo.putBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_OVERRIDDEN, false) + } catch (_: SecurityException) { + } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index b120323a4e..17173bfe72 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -68,6 +68,7 @@ import com.sameerasw.essentials.domain.model.NotificationLightingColorMode import com.sameerasw.essentials.domain.model.NotificationLightingSide import com.sameerasw.essentials.domain.model.NotificationLightingStyle import com.sameerasw.essentials.domain.model.NotificationLightingSweepPosition +import com.sameerasw.essentials.domain.model.PowerChoice import com.sameerasw.essentials.domain.model.RemapSlot import com.sameerasw.essentials.domain.model.ScaleAnimationsProfile import com.sameerasw.essentials.domain.model.SearchableItem @@ -87,6 +88,7 @@ import com.sameerasw.essentials.utils.DeviceUtils import com.sameerasw.essentials.utils.DuoBatteryLevels import com.sameerasw.essentials.utils.PermissionGrantUtil import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.PowerButtonGestures import com.sameerasw.essentials.utils.RefreshRateUtils import com.sameerasw.essentials.utils.RootUtils import com.sameerasw.essentials.utils.ScreenshotCleaner @@ -141,6 +143,8 @@ class MainViewModel : ViewModel() { val isButtonRemapPauseOnVolumeDialog = mutableStateOf(true) val shizukuDetectedDevicePath = mutableStateOf(null) val remapActions = mutableStateMapOf>() + val powerChoices = mutableStateMapOf() + val isPowerCameraFlipEnabled = mutableStateOf(false) val isHilightNotificationsEnabled = mutableStateOf(false) val isHilightOnlyWhenScreenOff = mutableStateOf(true) val isHilightSkipDnd = mutableStateOf(true) @@ -2394,7 +2398,12 @@ class MainViewModel : ViewModel() { RemapSlot.ALL.forEach { slot -> remapActions[slot] = settingsRepository.getRemapActions(slot.prefKey) + if (slot.choicePrefKey != null) { + powerChoices[slot] = settingsRepository.getPowerChoice(slot) + } } + isPowerCameraFlipEnabled.value = + settingsRepository.getBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP, false) isHilightNotificationsEnabled.value = settingsRepository.isHilightNotificationsEnabled() isHilightOnlyWhenScreenOff.value = settingsRepository.isHilightOnlyWhenScreenOff() @@ -5239,6 +5248,18 @@ class MainViewModel : ViewModel() { ) { isButtonRemapEnabled.value = enabled settingsRepository.putBoolean(SettingsRepository.KEY_BUTTON_REMAP_ENABLED, enabled) + if (!enabled) { + PowerButtonGestures.restoreDefaults(context, settingsRepository) + } else { + val hasNonDefaultChoice = + RemapSlot.ALL.any { slot -> + slot.choicePrefKey != null && + (powerChoices[slot] ?: slot.defaultChoice) != slot.defaultChoice + } + if (hasNonDefaultChoice || isPowerCameraFlipEnabled.value) { + PowerButtonGestures.sync(context, settingsRepository) + } + } } // Pause remap while the system volume dialog is visible @@ -5285,6 +5306,21 @@ class MainViewModel : ViewModel() { settingsRepository.setRemapActions(slot.prefKey, actions) } + fun setPowerChoice( + slot: RemapSlot, + choice: PowerChoice, + context: Context, + ) { + powerChoices[slot] = choice + settingsRepository.setPowerChoice(slot, choice) + PowerButtonGestures.sync(context, settingsRepository) + } + + fun setPowerCameraFlipEnabled(enabled: Boolean) { + isPowerCameraFlipEnabled.value = enabled + settingsRepository.putBoolean(SettingsRepository.KEY_BUTTON_REMAP_POWER_CAMERA_FLIP, enabled) + } + fun setVolumeStepsEnabled(enabled: Boolean) { isVolumeStepsEnabled.value = enabled settingsRepository.setVolumeStepsEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f972755999..3cea3e5111 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -246,6 +246,19 @@ Add action No actions. The button works normally. Actions run in order from top to bottom. Drag to reorder. + Button + Power + Double press + Press and hold + %1$s (default) + Digital assistant + Power menu + Custom actions + No actions. This gesture does nothing. + Double press always uses Android\'s camera gesture. Digital assistant uses Android\'s own gesture while both screen states keep it. + Essentials runs this gesture, so turn on \"%1$s\". Holding no longer opens the power menu unless you pick it. + Flip camera with double press + Double press opens the camera. While a camera app is open, double press switches between the front and back cameras. Needs \"%1$s\". May not work in every camera app. Open Activity Shortcut icon Activity @@ -1058,6 +1071,10 @@ Vibration feedback when remapped button is pressed Flashlight toggle Toggle flashlight with volume buttons + Flip camera with double press + Double press the power button to switch between front and back cameras + Power button press and hold + Digital assistant, power menu or custom actions Flashlight pocket detection Auto-turn off flashlight when placed in pocket Enable Dynamic Night Light @@ -1679,6 +1696,20 @@ pulse notification + + power + camera + flip + selfie + double + + + power + hold + long press + assistant + power menu + pocket proximity diff --git a/app/src/test/java/com/sameerasw/essentials/input/InputDeviceScannerTest.kt b/app/src/test/java/com/sameerasw/essentials/input/InputDeviceScannerTest.kt new file mode 100644 index 0000000000..773aabadb3 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/input/InputDeviceScannerTest.kt @@ -0,0 +1,48 @@ +package com.sameerasw.essentials.input + +import org.junit.Assert.assertEquals +import org.junit.Test + +class InputDeviceScannerTest { + @Test + fun parsePowerDevicesExtractsPowerDevices() { + val output = + """ + add device 1: /dev/input/event12 + name: "qwerty2" + events: + KEY (0001): KEY_ESC KEY_1 KEY_VOLUMEDOWN KEY_VOLUMEUP + KEY_POWER KEY_KPEQUAL + add device 2: /dev/input/event11 + name: "virtio_input_multi_touch_11" + events: + ABS (0003): ABS_MT_SLOT : value 0, min 0, max 9, fuzz 0, flat 0, resolution 0 + add device 13: /dev/input/event0 + name: "gpio-keys" + events: + KEY (0001): KEY_POWER + """.trimIndent() + + val devices = InputDeviceScanner.parsePowerDevices(output) + assertEquals(listOf("/dev/input/event12", "/dev/input/event0"), devices) + } + + @Test + fun parsePowerDevicesIgnoresSimilarKeyNames() { + val output = + """ + add device 1: /dev/input/event5 + name: "remote_control" + events: + KEY (0001): KEY_POWER2 + """.trimIndent() + + val devices = InputDeviceScanner.parsePowerDevices(output) + assertEquals(emptyList(), devices) + } + + @Test + fun parsePowerDevicesEmptyOutputReturnsEmptyList() { + assertEquals(emptyList(), InputDeviceScanner.parsePowerDevices("")) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/input/PowerGestureTrackerTest.kt b/app/src/test/java/com/sameerasw/essentials/input/PowerGestureTrackerTest.kt new file mode 100644 index 0000000000..0db4d94f8a --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/input/PowerGestureTrackerTest.kt @@ -0,0 +1,54 @@ +package com.sameerasw.essentials.input + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerGestureTrackerTest { + @Test + fun doublePressWithinWindow() { + val tracker = PowerGestureTracker(doublePressWindowMs = 300) + assertFalse(tracker.onDown(1000)) + assertTrue(tracker.onDown(1200)) + } + + @Test + fun doublePressOutsideWindow() { + val tracker = PowerGestureTracker(doublePressWindowMs = 300) + assertFalse(tracker.onDown(1000)) + assertFalse(tracker.onDown(1400)) + } + + @Test + fun threeQuickDownsOnlySecondIsDouble() { + val tracker = PowerGestureTracker(doublePressWindowMs = 300) + assertFalse(tracker.onDown(1000)) + assertTrue(tracker.onDown(1150)) + assertFalse(tracker.onDown(1300)) + } + + @Test + fun holdBetweenTwoQuickDownsPreventsDouble() { + val tracker = PowerGestureTracker(doublePressWindowMs = 300) + assertFalse(tracker.onDown(1000)) + tracker.onHold() + assertFalse(tracker.onDown(1150)) + } + + @Test + fun gestureStartFirstPressAndSecondPress() { + val tracker = PowerGestureTracker(doublePressWindowMs = 300) + assertTrue(tracker.isGestureStart(1000)) + assertFalse(tracker.onDown(1000)) + assertFalse(tracker.isGestureStart(1200)) + assertTrue(tracker.onDown(1200)) + assertTrue(tracker.isGestureStart(1400)) + } + + @Test + fun powerPressEventHasFirstPressAt() { + val event = PowerPressEvent(PowerGesture.DOUBLE_PRESS, screenOnBefore = true, firstPressAt = 12345L) + assertEquals(12345L, event.firstPressAt) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/utils/CameraFlipButtonTest.kt b/app/src/test/java/com/sameerasw/essentials/utils/CameraFlipButtonTest.kt new file mode 100644 index 0000000000..e90ae312bb --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/utils/CameraFlipButtonTest.kt @@ -0,0 +1,80 @@ +package com.sameerasw.essentials.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class CameraFlipButtonTest { + @Test + fun isFlipButtonMatches() { + assertTrue(CameraFlipButton.isFlipButton("com.android.camera2:id/camera_toggle_button", null)) + assertTrue(CameraFlipButton.isFlipButton("com.google.android.GoogleCamera:id/camera_switch_button", null)) + assertTrue(CameraFlipButton.isFlipButton(null, "Switch to front camera")) + assertFalse(CameraFlipButton.isFlipButton("com.android.camera2:id/shutter_button", "Shutter")) + assertFalse(CameraFlipButton.isFlipButton("com.android.camera2:id/mode_options_toggle", "Options")) + assertFalse(CameraFlipButton.isFlipButton("com.android.camera2:id/flash_toggle_button", "Flash auto")) + assertFalse(CameraFlipButton.isFlipButton(null, null)) + } + + @Test + fun lensInUseAtCameraOpenAcrossFirstPress() { + val sessions = listOf(CameraSession(lens = 1, openedAt = 1000L, closedAt = 3000L)) + assertEquals(1, lensInUseAt(sessions, 2000L)) + + val unclosedSessions = listOf(CameraSession(lens = 0, openedAt = 1000L, closedAt = null)) + assertEquals(0, lensInUseAt(unclosedSessions, 2000L)) + } + + @Test + fun lensInUseAtCameraOpenedAfterFirstPressReturnsNull() { + val sessions = listOf(CameraSession(lens = 1, openedAt = 2500L, closedAt = null)) + assertNull(lensInUseAt(sessions, 2000L)) + } + + @Test + fun lensInUseAtNoSessionsReturnsNull() { + assertNull(lensInUseAt(emptyList(), 2000L)) + } + + @Test + fun lensInUseAtSessionClosedBeforeFirstPressReturnsNull() { + val sessions = listOf(CameraSession(lens = 1, openedAt = 500L, closedAt = 1500L)) + assertNull(lensInUseAt(sessions, 2000L)) + } + + @Test + fun lensInUseAtTwoSessionsReturnsOldSessionLens() { + val sessions = + listOf( + CameraSession(lens = 0, openedAt = 1000L, closedAt = 2500L), + CameraSession(lens = 1, openedAt = 2200L, closedAt = null), + ) + assertEquals(0, lensInUseAt(sessions, 2100L)) + } + + @Test + fun openLensReturnsLatestOpenSession() { + assertNull(openLens(emptyList())) + assertNull(openLens(listOf(CameraSession(lens = 0, openedAt = 1000L, closedAt = 2000L)))) + assertEquals( + 1, + openLens( + listOf( + CameraSession(lens = 0, openedAt = 1000L, closedAt = 2000L), + CameraSession(lens = 1, openedAt = 2500L, closedAt = null), + ), + ), + ) + assertEquals( + 2, + openLens( + listOf( + CameraSession(lens = 1, openedAt = 1000L, closedAt = null), + CameraSession(lens = 2, openedAt = 2000L, closedAt = null), + ), + ), + ) + } +} diff --git a/app/src/test/java/com/sameerasw/essentials/utils/PowerButtonGesturesTest.kt b/app/src/test/java/com/sameerasw/essentials/utils/PowerButtonGesturesTest.kt new file mode 100644 index 0000000000..4b38534030 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/utils/PowerButtonGesturesTest.kt @@ -0,0 +1,45 @@ +package com.sameerasw.essentials.utils + +import com.sameerasw.essentials.domain.model.PowerChoice +import com.sameerasw.essentials.domain.model.RemapInput +import com.sameerasw.essentials.domain.model.RemapScreenState +import com.sameerasw.essentials.domain.model.RemapSlot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PowerButtonGesturesTest { + @Test + fun longPressValues() { + assertEquals(5, PowerButtonGestures.longPressValue(PowerChoice.ASSISTANT, PowerChoice.ASSISTANT)) + assertEquals(1, PowerButtonGestures.longPressValue(PowerChoice.POWER_MENU, PowerChoice.POWER_MENU)) + assertEquals(0, PowerButtonGestures.longPressValue(PowerChoice.ASSISTANT, PowerChoice.POWER_MENU)) + assertEquals(0, PowerButtonGestures.longPressValue(PowerChoice.ACTIONS, PowerChoice.ACTIONS)) + assertEquals(0, PowerButtonGestures.longPressValue(PowerChoice.POWER_MENU, PowerChoice.ACTIONS)) + } + + @Test + fun handledByEssentialsCombinations() { + assertFalse(PowerButtonGestures.isHandledByEssentials(PowerChoice.ASSISTANT, PowerChoice.ASSISTANT)) + assertFalse(PowerButtonGestures.isHandledByEssentials(PowerChoice.POWER_MENU, PowerChoice.POWER_MENU)) + assertTrue(PowerButtonGestures.isHandledByEssentials(PowerChoice.ASSISTANT, PowerChoice.POWER_MENU)) + assertTrue(PowerButtonGestures.isHandledByEssentials(PowerChoice.ACTIONS, PowerChoice.ACTIONS)) + assertTrue(PowerButtonGestures.isHandledByEssentials(PowerChoice.ASSISTANT, PowerChoice.ACTIONS)) + } + + @Test + fun remapSlotDefaultsAndKeys() { + assertEquals( + PowerChoice.ASSISTANT, + RemapSlot(RemapInput.POWER_HOLD, RemapScreenState.OFF).defaultChoice, + ) + assertNull(RemapSlot(RemapInput.VOLUME_UP, RemapScreenState.OFF).choicePrefKey) + assertNull(RemapSlot(RemapInput.VOLUME_UP, RemapScreenState.ON).choicePrefKey) + assertNull(RemapSlot(RemapInput.VOLUME_DOWN, RemapScreenState.OFF).choicePrefKey) + assertNull(RemapSlot(RemapInput.VOLUME_DOWN, RemapScreenState.ON).choicePrefKey) + assertNull(RemapSlot(RemapInput.VOLUME_UP, RemapScreenState.OFF).defaultChoice) + assertNull(RemapSlot(RemapInput.VOLUME_DOWN, RemapScreenState.ON).defaultChoice) + } +}