diff --git a/src/sessiond/device_state.rs b/src/sessiond/device_state.rs index ecd71c5..8eb39fc 100644 --- a/src/sessiond/device_state.rs +++ b/src/sessiond/device_state.rs @@ -71,6 +71,33 @@ pub const EVIDENCE_TTL: Duration = Duration::from_secs(30); /// periodically, so silence means silence rather than "nothing changed". pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90); +/// How long proximity must read `near` before the machine believes it. +/// +/// §9.5 specified Android's `DisplayPowerProximityStateController` — 0 ms +/// positive, 250 ms negative — and that is the wrong shape for this device. +/// Measured on the trail 2026-07-26: 44 near-episodes over 2.4 hours, median +/// dwell **1 second**, 15 of them sub-second, and a median 115 s of quiet +/// between them. That is not a sensor bouncing around a threshold; it is +/// isolated one-second blips. A negative debounce delays believing `far`, so it +/// would have turned each 1 s blip into a 1.25 s blip and left all 88 +/// transitions in place. +/// +/// Android debounces the negative edge because there `near` means *screen off +/// at the ear, immediately* — a positive delay would be felt. That constraint +/// left when proximity stopped actuating: it now only vetoes tap-to-wake, where +/// waiting is imperceptible and a false veto is the more annoying failure. +/// +/// Nine of the 44 episodes ran ≥5 s (max 173 s). Those are the real ones — +/// pocket, ear, deliberate cover — and they survive this threshold untouched. +pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700); + +/// How long proximity must read `far` before the machine believes it. +/// +/// Zero. An uncovered sensor is believed at once: the reading that ends a veto +/// should never be the slow one, and erring toward `far` errs toward letting a +/// wake through, which is the recoverable direction. +pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO; + /// How long a blank waits for the compositor to acknowledge the lock it asked /// for before going dark anyway. /// @@ -119,6 +146,10 @@ pub struct DeviceStatePolicy { pub unlocked_blank_after: Option, /// A source silent for this long, having previously reported, is down. pub source_down_after: Duration, + /// How long proximity must hold `near` before the machine believes it. + pub proximity_near_debounce: Duration, + /// How long proximity must hold `far` before the machine believes it. + pub proximity_far_debounce: Duration, } impl DeviceStatePolicy { @@ -200,6 +231,8 @@ impl Default for DeviceStatePolicy { lock_ack_budget: LOCK_ACK_BUDGET, unlocked_blank_after: None, source_down_after: SOURCE_DOWN_AFTER, + proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE, + proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE, } } } @@ -915,6 +948,11 @@ pub struct DeviceStateMachine { /// deadline after which the panel goes dark regardless — see /// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice. pending_blank: Option, + /// The last raw proximity reading, before debounce. `sensor_evidence` + /// carries the believed value; this carries what the sensor actually said. + proximity_raw: bool, + /// When the raw reading last changed. The debounce measures from here. + proximity_since: Option, /// Timed policy. Settable, so Settings can own it. pub policy: DeviceStatePolicy, } @@ -981,6 +1019,8 @@ impl DeviceStateMachine { blank_requested: false, dimmed: false, pending_blank: None, + proximity_raw: false, + proximity_since: None, // Loaded, not defaulted: the user's Auto-Lock choices are settings, // and a setting that reverts on reboot is not a setting. policy: DeviceStatePolicy::load(), @@ -1018,7 +1058,11 @@ impl DeviceStateMachine { /// `tick` with the clock passed in, so the rules can be tested at any /// point on the timeline instead of by sleeping. pub fn tick_at(&mut self, now: Instant) -> Vec { + // 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. self.expire_stale_evidence(now); + self.resolve_proximity_debounce(now); self.evaluate_source_health(now); let mut actions = Vec::new(); @@ -1212,6 +1256,12 @@ impl DeviceStateMachine { &mut self.evidence_seen.proximity, &mut self.sensor_evidence.proximity_near, ) { + // Clear the raw reading and any pending debounce with it. Stale + // means unknown, and a half-resolved edge left behind an expiry + // would let a reading nobody has confirmed in 30 s win the moment + // the next tick ran. + self.proximity_raw = false; + self.proximity_since = None; dropped.push("proximity"); } if expired( @@ -1598,6 +1648,87 @@ impl DeviceStateMachine { actions } + /// Decide whether to believe a raw proximity reading yet. + /// + /// Returns the *believed* value. A raw reading that disagrees with the + /// believed one starts a clock; it only wins once it has held for the + /// direction's threshold. `resolve_proximity_debounce` finishes the job on + /// the tick, for the case where the sensor reports once and goes quiet. + fn debounce_proximity(&mut self, raw: bool, now: Instant) -> bool { + if raw != self.proximity_raw { + self.proximity_raw = raw; + self.proximity_since = Some(now); + } + let believed = self.sensor_evidence.proximity_near; + if raw == believed { + // Nothing pending — the sensor agrees with what we already think. + self.proximity_since = None; + return believed; + } + let held_for = self + .proximity_since + .map(|t| now.saturating_duration_since(t)) + .unwrap_or_default(); + let threshold = if raw { + self.policy.proximity_near_debounce + } else { + self.policy.proximity_far_debounce + }; + if held_for >= threshold { + self.proximity_since = None; + raw + } else { + believed + } + } + + /// Let a held reading win once its threshold passes with no new report. + /// + /// Without this the debounce would only resolve when the next reading + /// arrives, and a reporter that heartbeats every 30 s would make a real + /// `near` take up to half a minute to be believed. + fn resolve_proximity_debounce(&mut self, now: Instant) { + if self.proximity_raw == self.sensor_evidence.proximity_near { + return; + } + let held_for = self + .proximity_since + .map(|t| now.saturating_duration_since(t)) + .unwrap_or_default(); + let believed = self.debounce_proximity(self.proximity_raw, now); + if believed != self.sensor_evidence.proximity_near { + self.sensor_evidence.proximity_near = believed; + + // Record the edge. Without this the trail would carry a + // Locked → Observed transition with no sensor input behind it — + // the reading was filtered on the way in and believed a tick + // later, so nothing would say why the machine moved. Filtered + // blips stay out of the trail deliberately; the edge that wins + // does not. + let confidence = self.sensor_evidence.confidence(); + let snapshot = self.snapshot("", false, false, false, "", false); + self.forensic.append( + ForensicEvent::SensorInput { + source: SensorSource::Proximity, + value: SensorValue::Near(believed), + confidence, + }, + snapshot, + &format!( + "proximity={believed} believed after holding {} ms", + held_for.as_millis() + ), + ); + + let should_be_observed = self.state == DeviceState::Locked && believed; + if should_be_observed { + self.transition(DeviceState::Observed); + } else if matches!(self.state, DeviceState::Observed) && !believed { + self.transition(DeviceState::Locked); + } + } + } + /// Proximity-shaped convenience wrapper. pub fn update_sensors(&mut self, evidence: SensorEvidence) { self.update_sensors_from(SensorSource::Proximity, evidence) @@ -1607,6 +1738,27 @@ impl DeviceStateMachine { /// potentially transition to/from Observed. /// Emits forensic entries for every sensor input. pub fn update_sensors_from(&mut self, source: SensorSource, evidence: SensorEvidence) { + self.update_sensors_from_at(source, evidence, Instant::now()) + } + + /// `update_sensors_from` with the clock passed in, so the debounce can be + /// tested on a timeline instead of by sleeping. + pub fn update_sensors_from_at( + &mut self, + source: SensorSource, + mut evidence: SensorEvidence, + now: Instant, + ) { + // Proximity is debounced before it is believed. The rest of the + // evidence is taken as reported — none of it flaps the way this one + // does, and none of it has the measurement behind it that would justify + // picking a threshold. + if source == SensorSource::Proximity { + evidence.proximity_near = self.debounce_proximity(evidence.proximity_near, now); + } else { + evidence.proximity_near = self.sensor_evidence.proximity_near; + } + let was_observed = matches!(self.state, DeviceState::Observed); let proximity_near = evidence.proximity_near; let should_be_observed = self.state == DeviceState::Locked && proximity_near; @@ -2257,6 +2409,151 @@ mod tests { assert!(!e.should_promote_idle_faster()); } + // ── Proximity debounce ──────────────────────────────────────────── + + /// A locked machine and a clock, for driving proximity on a timeline. + fn locked_for_proximity() -> (DeviceStateMachine, Instant) { + let mut sm = DeviceStateMachine::new(); + sm.transition(DeviceState::Locked); + (sm, Instant::now()) + } + + fn prox(near: bool) -> SensorEvidence { + SensorEvidence { + proximity_near: near, + ..Default::default() + } + } + + /// Report proximity the way `server.rs` does — the reading and the + /// freshness stamp together. A test that only does the first half has a + /// source that is instantly stale. + fn report_prox(sm: &mut DeviceStateMachine, near: bool, at: Instant) { + sm.mark_evidence_seen_at(SensorSource::Proximity, at); + sm.update_sensors_from_at(SensorSource::Proximity, prox(near), at); + } + + /// Report `near` and let it hold long enough to be believed. + fn hold_prox_near(sm: &mut DeviceStateMachine, at: Instant) -> Instant { + report_prox(sm, true, at); + let settled = at + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1); + sm.mark_evidence_seen_at(SensorSource::Proximity, settled); + sm.tick_at(settled); + settled + } + + #[test] + fn a_sub_second_near_blip_is_never_believed() { + // The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median + // dwell 1 s, 15 of them sub-second. Each one produced two transitions + // and two trail entries for a reading that meant nothing. + let (mut sm, t0) = locked_for_proximity(); + + report_prox(&mut sm, true, t0); + assert_eq!(sm.state, DeviceState::Locked, "near is not believed yet"); + assert!(!sm.sensor_evidence.proximity_near); + + report_prox(&mut sm, false, t0 + Duration::from_millis(400)); + sm.tick_at(t0 + Duration::from_millis(500)); + assert_eq!( + sm.state, + DeviceState::Locked, + "the blip must leave no transition behind" + ); + } + + #[test] + fn a_held_near_is_believed_once_it_has_held() { + // The nine real episodes ran 5 s to 173 s. They must survive untouched. + let (mut sm, t0) = locked_for_proximity(); + report_prox(&mut sm, true, t0); + assert_eq!(sm.state, DeviceState::Locked); + + // No further reading arrives — the tick has to finish the job, or a + // reporter that heartbeats every 30 s would delay a real near by half + // a minute. + hold_prox_near(&mut sm, t0); + assert!(sm.sensor_evidence.proximity_near); + assert_eq!(sm.state, DeviceState::Observed); + } + + #[test] + fn far_is_believed_at_once() { + // The reading that ends a veto is never the slow one. + let (mut sm, t0) = locked_for_proximity(); + hold_prox_near(&mut sm, t0); + assert_eq!(sm.state, DeviceState::Observed); + + let t1 = t0 + Duration::from_secs(10); + report_prox(&mut sm, false, t1); + assert!(!sm.sensor_evidence.proximity_near); + assert_eq!(sm.state, DeviceState::Locked); + } + + #[test] + fn a_flapping_sensor_that_never_settles_is_never_believed() { + // Alternating faster than the threshold: the near edge keeps restarting + // its clock, so nothing is ever believed and the trail stays quiet. + let (mut sm, t0) = locked_for_proximity(); + let mut t = t0; + for _ in 0..20 { + report_prox(&mut sm, true, t); + t += Duration::from_millis(200); + report_prox(&mut sm, false, t); + t += Duration::from_millis(200); + } + assert_eq!(sm.state, DeviceState::Locked); + assert!(!sm.sensor_evidence.proximity_near); + } + + #[test] + fn a_heartbeat_repeat_does_not_restart_the_debounce() { + // Reporters re-send their last value every 30 s (§10). A repeat is the + // same edge continuing, not a new one — if it reset the clock, a held + // near would never be believed. + let (mut sm, t0) = locked_for_proximity(); + report_prox(&mut sm, true, t0); + report_prox(&mut sm, true, t0 + Duration::from_millis(500)); + report_prox( + &mut sm, + true, + t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1), + ); + assert!(sm.sensor_evidence.proximity_near); + assert_eq!(sm.state, DeviceState::Observed); + } + + #[test] + fn a_non_proximity_reading_does_not_disturb_the_debounce() { + // accel/light/touch share the evidence struct; updating one must not + // silently overwrite a proximity value mid-debounce. + let (mut sm, t0) = locked_for_proximity(); + report_prox(&mut sm, true, t0); + + let accel = SensorEvidence { + proximity_near: false, // the caller's stale copy — must be ignored + accel_moving: true, + ..Default::default() + }; + sm.mark_evidence_seen_at(SensorSource::Accelerometer, t0 + Duration::from_millis(100)); + sm.update_sensors_from_at( + SensorSource::Accelerometer, + accel, + t0 + Duration::from_millis(100), + ); + + sm.mark_evidence_seen_at( + SensorSource::Proximity, + t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1), + ); + sm.tick_at(t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1)); + assert!(sm.sensor_evidence.accel_moving); + assert!( + sm.sensor_evidence.proximity_near, + "the proximity edge must still resolve on its own clock" + ); + } + #[test] fn only_tap_to_wake_is_vetoed_by_a_covered_sensor() { // A double tap is the one wake a pocket can produce by itself. The @@ -2280,11 +2577,9 @@ mod tests { sm.transition(DeviceState::Dimmed); sm.transition(DeviceState::Locked); - let evidence = SensorEvidence { - proximity_near: true, - ..Default::default() - }; - sm.update_sensors(evidence); + // Held, not blipped — near is debounced now, so a reading that does not + // last is a reading the machine never believed. + hold_prox_near(&mut sm, Instant::now()); assert_eq!(sm.state(), DeviceState::Observed); } @@ -2295,11 +2590,9 @@ mod tests { sm.transition(DeviceState::Locked); // Enter observed - sm.update_sensors(SensorEvidence { - proximity_near: true, - ..Default::default() - }); + let settled = hold_prox_near(&mut sm, Instant::now()); assert_eq!(sm.state(), DeviceState::Observed); + let _ = settled; // Leave observed sm.update_sensors(SensorEvidence { @@ -2354,7 +2647,9 @@ mod tests { "native IdleMonitor fired, requesting lock", ); - // 4. Phone goes into pocket — proximity near + // 4. Phone goes into pocket — proximity near, held. A pocket is not a + // one-second blip; the debounce is what tells them apart. + hold_prox_near(&mut sm, Instant::now()); sm.update_sensors(SensorEvidence { proximity_near: true, accel_moving: false, @@ -2388,11 +2683,9 @@ mod tests { sm.transition(DeviceState::Locked); sm.record_wake(WakeTrigger::PowerButton, "user pressed power button"); - // 8. Phone goes back in pocket briefly - sm.update_sensors(SensorEvidence { - proximity_near: true, - ..Default::default() - }); + // 8. Phone goes back in pocket. Held — "briefly" is precisely what the + // debounce now refuses to believe, which is the point of it. + hold_prox_near(&mut sm, Instant::now()); assert_eq!(sm.state(), DeviceState::Observed); // 9. Phone comes back out @@ -2526,13 +2819,20 @@ mod tests { let mut sm = DeviceStateMachine::new(); sm.transition(DeviceState::Dimmed); sm.transition(DeviceState::Locked); - // Proximity near + accel moving = walking with phone - sm.update_sensors(SensorEvidence { - proximity_near: true, - accel_moving: true, - light_changing: false, - touch_active: false, - }); + // Proximity near + accel moving = walking with phone. Near is + // debounced, so it has to be held before the pair is on the record. + let t0 = Instant::now(); + hold_prox_near(&mut sm, t0); + sm.update_sensors_from_at( + SensorSource::Accelerometer, + SensorEvidence { + proximity_near: true, + accel_moving: true, + light_changing: false, + touch_active: false, + }, + t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(2), + ); let entries = sm.forensic.recent(10); let sensor_entries: Vec<_> = entries .iter() @@ -2540,7 +2840,7 @@ mod tests { .collect(); assert!(!sensor_entries.is_empty()); // The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5) - match &sensor_entries[0].event { + match &sensor_entries[sensor_entries.len() - 1].event { ForensicEvent::SensorInput { confidence, .. } => { assert!( (confidence - 0.5).abs() < 0.01, @@ -2708,15 +3008,13 @@ mod tests { // `near` across a lock flips should_be_observed with no change in the // evidence at all — an early return here would strand the machine. let mut sm = DeviceStateMachine::new(); - let near = SensorEvidence { - proximity_near: true, - ..Default::default() - }; - sm.update_sensors(near.clone()); + let t0 = Instant::now(); + let settled = hold_prox_near(&mut sm, t0); assert_eq!(sm.state, DeviceState::Active); + assert!(sm.sensor_evidence.proximity_near, "near is believed by now"); sm.transition(DeviceState::Locked); - sm.update_sensors(near); + report_prox(&mut sm, true, settled + Duration::from_secs(1)); assert_eq!( sm.state, DeviceState::Observed,