sessiond: the power button reports edges; the machine recognises gestures
blueline-power-button decided policy in shell script — read its own panel state, asked the shell to lock over qs ipc, polled, then blanked the panel itself. That was a path to a dark panel outside request_blank(), so §1's lock-then-blank invariant had a hole in the most-used control on the device. Edges in, gestures out: tap, double, triple, hold, long-hold, per button. Only the power tap is bound; the rest are recognised, recorded and inert until there is a binding table to point them at.
This commit is contained in:
parent
5c618f1514
commit
86afe00dba
3 changed files with 322 additions and 2 deletions
|
|
@ -20,7 +20,9 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{Duration, Instant};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::sessiond::protocol::{InputTrigger, SensorSource, SensorValue};
|
||||
use crate::sessiond::protocol::{
|
||||
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue,
|
||||
};
|
||||
|
||||
/// How long a locked, lit panel waits for input before it blanks.
|
||||
///
|
||||
|
|
@ -123,6 +125,31 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
|
|||
/// wake through, which is the recoverable direction.
|
||||
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
|
||||
|
||||
/// How long a button must stay down to be a hold rather than a tap.
|
||||
///
|
||||
/// **Provisional, and the number is the weakest part of this file.** §9.5's
|
||||
/// lesson is the standing warning here: the proximity debounce was specified
|
||||
/// from Android's prior art, and when it came time to build it the trail said
|
||||
/// the specification was wrong for a device whose constraints had changed. 500
|
||||
/// ms is AOSP's long-press default and it is a *reference*, not a measurement.
|
||||
/// The trail now records every gesture with the duration that produced it, so
|
||||
/// this can be set from real presses the way `PROXIMITY_NEAR_DEBOUNCE` was —
|
||||
/// do that before defending the value.
|
||||
pub const BUTTON_HOLD: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Held past this, still down. The point where a destructive binding may fire
|
||||
/// without the user having meant a hold.
|
||||
pub const BUTTON_LONG_HOLD: Duration = Duration::from_millis(2000);
|
||||
|
||||
/// How long after a release to keep waiting for another tap.
|
||||
///
|
||||
/// This one is felt directly and in the wrong direction: it is the delay
|
||||
/// between a single tap and anything happening, because a single cannot fire
|
||||
/// until the window proves no second tap is coming. Too long and the phone
|
||||
/// feels broken; too short and a double-tap fires a single first. 300 ms is
|
||||
/// AOSP's double-tap timeout. Same caveat as above — measure it.
|
||||
pub const BUTTON_MULTI_TAP_WINDOW: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How long a blank waits for the compositor to acknowledge the lock it asked
|
||||
/// for before going dark anyway.
|
||||
///
|
||||
|
|
@ -729,6 +756,96 @@ impl ForensicLog {
|
|||
}
|
||||
}
|
||||
|
||||
/// One button's edges, accumulating into gestures.
|
||||
///
|
||||
/// This is the chordotonal principle applied to a button: the thing that turns
|
||||
/// a run of drivers (down, up, down, up) into one piece of evidence ("double
|
||||
/// tap") lives below the decision, and only the compressed answer travels
|
||||
/// upward. A consumer never sees the edges.
|
||||
///
|
||||
/// Deliberately per-button and stateless about *meaning*. It recognises; it
|
||||
/// does not decide what a triple-tap is for. Binding is policy and lives above.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ButtonRecognizer {
|
||||
/// When the button went down, while it is down.
|
||||
down_since: Option<Instant>,
|
||||
/// Holds already announced for the current press, so `tick` does not
|
||||
/// re-fire them every second while a finger rests on the button.
|
||||
hold_fired: bool,
|
||||
long_hold_fired: bool,
|
||||
/// Releases that have not yet resolved into a tap gesture, and when the
|
||||
/// most recent one landed.
|
||||
pending_taps: u8,
|
||||
last_release: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ButtonRecognizer {
|
||||
/// A press edge. Returns nothing — a press alone is never a gesture, and
|
||||
/// pretending otherwise is what makes a double-tap fire a single first.
|
||||
pub fn down(&mut self, now: Instant) {
|
||||
self.down_since = Some(now);
|
||||
self.hold_fired = false;
|
||||
self.long_hold_fired = false;
|
||||
}
|
||||
|
||||
/// A release edge. Returns a gesture only when the release *completes* one
|
||||
/// immediately — which is never, for taps. A hold that already fired
|
||||
/// resolves to nothing here: it was announced while the button was down,
|
||||
/// and announcing it again on release would double-fire every binding.
|
||||
pub fn up(&mut self, now: Instant) -> Option<ButtonGesture> {
|
||||
let held = self.down_since.take().map(|t| now.duration_since(t));
|
||||
if self.hold_fired {
|
||||
// A hold was already announced. Releasing ends it and starts no
|
||||
// tap — a long press is not also a tap, and counting it as one is
|
||||
// how "hold to power off" also toggles your screen.
|
||||
self.pending_taps = 0;
|
||||
self.last_release = None;
|
||||
return None;
|
||||
}
|
||||
if held.is_some_and(|d| d >= BUTTON_HOLD) {
|
||||
// Held long enough, but tick never saw it (a press shorter than one
|
||||
// tick interval that still crossed the threshold). Announce now.
|
||||
self.pending_taps = 0;
|
||||
self.last_release = None;
|
||||
return Some(ButtonGesture::Hold);
|
||||
}
|
||||
self.pending_taps = self.pending_taps.saturating_add(1);
|
||||
self.last_release = Some(now);
|
||||
None
|
||||
}
|
||||
|
||||
/// Called on every tick. Fires holds while the button is still down, and
|
||||
/// resolves pending taps once the multi-tap window has closed.
|
||||
pub fn tick(&mut self, now: Instant) -> Option<ButtonGesture> {
|
||||
if let Some(since) = self.down_since {
|
||||
let held = now.duration_since(since);
|
||||
if held >= BUTTON_LONG_HOLD && !self.long_hold_fired {
|
||||
self.long_hold_fired = true;
|
||||
return Some(ButtonGesture::LongHold);
|
||||
}
|
||||
if held >= BUTTON_HOLD && !self.hold_fired {
|
||||
self.hold_fired = true;
|
||||
return Some(ButtonGesture::Hold);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let last = self.last_release?;
|
||||
if now.duration_since(last) < BUTTON_MULTI_TAP_WINDOW {
|
||||
return None;
|
||||
}
|
||||
let n = std::mem::take(&mut self.pending_taps);
|
||||
self.last_release = None;
|
||||
match n {
|
||||
0 => None,
|
||||
1 => Some(ButtonGesture::Tap),
|
||||
2 => Some(ButtonGesture::DoubleTap),
|
||||
// Four taps is a triple plus a stray, not a new gesture. Saturating
|
||||
// here beats inventing a QuadrupleTap nobody asked for.
|
||||
_ => Some(ButtonGesture::TripleTap),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this entry worth waking a mind for?
|
||||
///
|
||||
/// The whole value of the subscribe stream is this predicate. §10 is the
|
||||
|
|
@ -1130,6 +1247,9 @@ pub struct DeviceStateMachine {
|
|||
/// expected source which has not reported *yet* from one that is never
|
||||
/// going to — see `SOURCE_EXPECTED_WITHIN`.
|
||||
started_at: Instant,
|
||||
/// Per-button gesture recognition. Keyed rather than a field per button so
|
||||
/// adding volume costs nothing and binds nothing.
|
||||
buttons: std::collections::HashMap<Button, ButtonRecognizer>,
|
||||
/// True once a blank has been asked for, so we ask exactly once per wake
|
||||
/// instead of every tick.
|
||||
blank_requested: bool,
|
||||
|
|
@ -1225,6 +1345,7 @@ impl DeviceStateMachine {
|
|||
evidence_seen: EvidenceSeen::default(),
|
||||
source_health: SourceHealthTable::default(),
|
||||
started_at: Instant::now(),
|
||||
buttons: std::collections::HashMap::new(),
|
||||
blank_requested: false,
|
||||
dimmed: false,
|
||||
pending_blank: None,
|
||||
|
|
@ -1267,7 +1388,113 @@ impl DeviceStateMachine {
|
|||
|
||||
/// `tick` with the clock passed in, so the rules can be tested at any
|
||||
/// point on the timeline instead of by sleeping.
|
||||
/// A button edge. Returns any actions the resulting gesture asks for.
|
||||
///
|
||||
/// A press is user input regardless of what it turns out to mean, so the
|
||||
/// idle budget resets on the DOWN edge, not on the gesture. Waiting for
|
||||
/// recognition would let the multi-tap window count against a user who is
|
||||
/// visibly touching the device.
|
||||
pub fn button_edge(&mut self, button: Button, edge: ButtonEdge) -> Vec<Action> {
|
||||
self.button_edge_at(button, edge, Instant::now())
|
||||
}
|
||||
|
||||
pub fn button_edge_at(
|
||||
&mut self,
|
||||
button: Button,
|
||||
edge: ButtonEdge,
|
||||
now: Instant,
|
||||
) -> Vec<Action> {
|
||||
let rec = self.buttons.entry(button).or_default();
|
||||
let gesture = match edge {
|
||||
ButtonEdge::Down => {
|
||||
rec.down(now);
|
||||
None
|
||||
}
|
||||
ButtonEdge::Up => rec.up(now),
|
||||
};
|
||||
let mut actions = Vec::new();
|
||||
if edge == ButtonEdge::Down {
|
||||
// A hardware button is intent — §4's table says of the power button
|
||||
// "can it lie? no — hardware signal" — so it is never subject to the
|
||||
// proximity veto that governs tap-to-wake and squeeze. Input is
|
||||
// noted on the DOWN edge because a finger on the button is a user
|
||||
// present, whatever the press turns out to mean; making the idle
|
||||
// budget wait for recognition would count the multi-tap window
|
||||
// against someone visibly touching the device.
|
||||
actions.extend(self.note_input(InputTrigger::PowerButton));
|
||||
}
|
||||
if let Some(g) = gesture {
|
||||
actions.extend(self.apply_gesture(button, g, now));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
/// Bind a recognised gesture to behaviour.
|
||||
///
|
||||
/// **This match is a placeholder for a binding table.** The end state is
|
||||
/// Activator-shaped: `(button, gesture) -> action` as editable, persisted
|
||||
/// data, so a triple-tap can be pointed at something without a rebuild.
|
||||
/// `DeviceStatePolicy` is already the persisted home for exactly this kind
|
||||
/// of setting ("a setting that reverts on reboot is not a setting") and is
|
||||
/// where the table belongs. Recognition is deliberately separate from
|
||||
/// binding so that change touches only this function.
|
||||
///
|
||||
/// Only the power tap is bound today, and it is bound to what the button
|
||||
/// already did — wake if the panel is dark, otherwise lock-then-blank
|
||||
/// through `request_blank()` like every other path. Everything else is
|
||||
/// recognised, recorded, and inert on purpose: a gesture that fires
|
||||
/// something nobody chose is worse than one that fires nothing.
|
||||
fn apply_gesture(
|
||||
&mut self,
|
||||
button: Button,
|
||||
gesture: ButtonGesture,
|
||||
now: Instant,
|
||||
) -> Vec<Action> {
|
||||
self.record_decision(
|
||||
"button-gesture",
|
||||
serde_json::json!({
|
||||
"button": button.as_str(),
|
||||
"gesture": gesture.as_str(),
|
||||
"bound": button == Button::Power && gesture == ButtonGesture::Tap,
|
||||
}),
|
||||
"hardware button recognised",
|
||||
);
|
||||
if button != Button::Power || gesture != ButtonGesture::Tap {
|
||||
return Vec::new();
|
||||
}
|
||||
if !self.panel_on {
|
||||
// Dark: the tap is a wake, and a power-button wake is never vetoed.
|
||||
// `Restore` puts back the brightness the dim captured rather than a
|
||||
// guessed floor — see the comment on that capture.
|
||||
self.blank_requested = false;
|
||||
return vec![Action::Restore];
|
||||
}
|
||||
self.request_blank(
|
||||
now,
|
||||
serde_json::json!({ "button": "power", "gesture": "tap" }),
|
||||
"power button tap",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
|
||||
// Buttons first: a hold that crossed its threshold during this tick
|
||||
// should be announced before the idle rules below decide anything, or
|
||||
// a hold and a blank can land in the same tick in the wrong order.
|
||||
let mut button_actions = Vec::new();
|
||||
let pending: Vec<(Button, ButtonGesture)> = self
|
||||
.buttons
|
||||
.iter_mut()
|
||||
.filter_map(|(b, r)| r.tick(now).map(|g| (*b, g)))
|
||||
.collect();
|
||||
for (button, gesture) in pending {
|
||||
button_actions.extend(self.apply_gesture(button, gesture, now));
|
||||
}
|
||||
let mut actions = self.tick_rules_at(now);
|
||||
button_actions.append(&mut actions);
|
||||
return button_actions;
|
||||
}
|
||||
|
||||
fn tick_rules_at(&mut self, now: Instant) -> Vec<Action> {
|
||||
// Expiry first. A source that has gone stale must not be allowed to
|
||||
// win a pending debounce and then be cleared in the same tick — that
|
||||
// would put a transition in the trail for a reading nobody confirmed.
|
||||
|
|
|
|||
|
|
@ -174,6 +174,13 @@ pub const VERBS: &[VerbDoc] = &[
|
|||
refuses: &[RefusalCode::RefusedByState],
|
||||
example: r#"{"op":"input","trigger":"touch"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "button",
|
||||
mutates: true,
|
||||
summary: "a hardware button edge; the machine recognises taps and holds from these",
|
||||
refuses: &[RefusalCode::InvalidArgument],
|
||||
example: r#"{"op":"button","button":"power","edge":"down"}"#,
|
||||
},
|
||||
VerbDoc {
|
||||
op: "panel",
|
||||
mutates: true,
|
||||
|
|
@ -258,6 +265,23 @@ pub enum Request {
|
|||
/// This is the wire that lets the machine tell "the user is looking at
|
||||
/// the lock screen" from "the lock screen has been lit for ten minutes."
|
||||
Input { trigger: Option<InputTrigger> },
|
||||
/// A hardware button went down or came up. Edges only — the caller reports
|
||||
/// what the hardware did and nothing else.
|
||||
///
|
||||
/// `blueline-power-button` used to *be* the policy: it read a state file,
|
||||
/// asked the shell to lock over `qs ipc`, polled `session state` twenty
|
||||
/// times at 100 ms grepping for `"locked": true`, then called
|
||||
/// `blueline-screen-toggle off` itself. That is a path to a dark panel that
|
||||
/// never routes through `request_blank()` — so LOCK-DPMS-LESSONS §1's
|
||||
/// "every path routes through it, an invariant not a coincidence" had a hole
|
||||
/// in it, and the hole was the most-used control on the device. It also made
|
||||
/// the button the eighth blind actor of DEVICE-STATE-MACHINE §1, with its
|
||||
/// own copy of the lock-then-blank ordering and its own panel truth.
|
||||
///
|
||||
/// Now it reports and stops deciding. Recognition — tap, double, triple,
|
||||
/// hold — happens in the machine, because a gesture is an accumulation over
|
||||
/// time and time is the one thing a fire-and-forget script does not have.
|
||||
Button { button: Button, edge: ButtonEdge },
|
||||
/// The DPMS executor reports the panel's real power state. The machine
|
||||
/// keeps the panel as a field, not a state: "locked with the screen off"
|
||||
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
|
||||
|
|
@ -326,6 +350,65 @@ pub enum InputTrigger {
|
|||
Unknown,
|
||||
}
|
||||
|
||||
/// Which hardware button. Volume is here because the recognizer is per-button
|
||||
/// and costs nothing to reuse; nothing binds them yet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Button {
|
||||
Power,
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
}
|
||||
|
||||
impl Button {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Button::Power => "power",
|
||||
Button::VolumeUp => "volume_up",
|
||||
Button::VolumeDown => "volume_down",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ButtonEdge {
|
||||
Down,
|
||||
Up,
|
||||
}
|
||||
|
||||
/// What the machine made of a run of edges.
|
||||
///
|
||||
/// `Hold` fires while the button is still down — a hold you only learn about on
|
||||
/// release is a hold that cannot light anything up while you are waiting, and
|
||||
/// waiting with no feedback is how a user decides the device is broken and lets
|
||||
/// go. Taps resolve on the multi-tap window expiring, which is the opposite
|
||||
/// trade and the right one: a double-tap must not first fire a single.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ButtonGesture {
|
||||
Tap,
|
||||
DoubleTap,
|
||||
TripleTap,
|
||||
/// Held past the first threshold, still down.
|
||||
Hold,
|
||||
/// Held past the second, still down. The "you may let go now" point for
|
||||
/// anything destructive — nothing binds it yet.
|
||||
LongHold,
|
||||
}
|
||||
|
||||
impl ButtonGesture {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ButtonGesture::Tap => "tap",
|
||||
ButtonGesture::DoubleTap => "double_tap",
|
||||
ButtonGesture::TripleTap => "triple_tap",
|
||||
ButtonGesture::Hold => "hold",
|
||||
ButtonGesture::LongHold => "long_hold",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A sensor reading fed to the device state machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SensorInput {
|
||||
|
|
@ -421,7 +504,7 @@ mod tests {
|
|||
|
||||
// And the other direction: every variant must be advertised. Bump this
|
||||
// deliberately when a verb is added, having added its VerbDoc.
|
||||
assert_eq!(VERBS.len(), 13, "a Request variant was added without a VerbDoc");
|
||||
assert_eq!(VERBS.len(), 14, "a Request variant was added without a VerbDoc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -980,6 +980,16 @@ fn handle_request(
|
|||
}
|
||||
serde_json::json!({ "ok": true, "dim_cancelled": cancelled })
|
||||
}
|
||||
Request::Button { button, edge } => {
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
d.device_state.button_edge(button, edge)
|
||||
};
|
||||
for action in actions {
|
||||
execute(shared, action);
|
||||
}
|
||||
serde_json::json!({ "ok": true })
|
||||
}
|
||||
Request::Panel { on } => {
|
||||
let actions = {
|
||||
let mut d = shared.lock();
|
||||
|
|
|
|||
Loading…
Reference in a new issue