Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@ import com.sameerasw.essentials.data.repository.SettingsRepository
enum class RemapInput {
VOLUME_UP,
VOLUME_DOWN,
POWER_HOLD,
}

enum class RemapScreenState {
OFF,
ON,
}

enum class PowerChoice {
ASSISTANT,
POWER_MENU,
ACTIONS,
}

data class RemapSlot(
val input: RemapInput,
val screenState: RemapScreenState,
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -42,4 +44,32 @@ class InputDeviceScanner {
}
return devices
}

fun scanForPowerDevices(context: Context): List<String> {
val output = ShellUtils.runCommandWithOutput(context, "getevent -pl", notifyOnError = false)
return if (output != null) parsePowerDevices(output) else emptyList()
}

companion object {
fun parsePowerDevices(output: String): List<String> {
val result = mutableListOf<String>()
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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PowerPressEvent>()
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()
}
}
Loading
Loading