Skip to main content

azul_layout/managers/
geolocation.rs

1//! Geolocation manager — cross-platform state for the GPS/location surface
2//! (`SUPER_PLAN_2` §1.5 + research/04 §3 + research/08 §6).
3//!
4//! Three callers drive it:
5//!
6//! - The **layout pass** scans the styled DOM for `GeolocationProbe`
7//!   `NodeTypes`. When the first probe appears the framework fires
8//!   `PermissionDiffEvent::Subscribe(Capability::Geolocation)` and the
9//!   platform backend starts a native `CLLocationManager` /
10//!   `LocationManager` / `geoclue` subscription. The reverse on the
11//!   last probe leaving.
12//!
13//! - The **platform backend** (`dll/src/desktop/extra/geolocation/<plat>.rs`)
14//!   calls `set_latest_fix(...)` whenever the native subscription
15//!   delivers an update. The manager debounces and records the most
16//!   recent value; callbacks read it via `CallbackInfo::get_geolocation_fix`.
17//!
18//! - **Callbacks** read `latest_fix()` synchronously to render the map
19//!   centre, decide whether to show "acquiring signal…", etc.
20//!
21//! No platform deps; `no_std`-friendly via `alloc::collections::BTreeMap`.
22
23use alloc::collections::btree_map::BTreeMap;
24use alloc::vec::Vec;
25
26use azul_core::dom::DomNodeId;
27use azul_core::events::{
28    EventData, EventProvider, EventSource as CoreEventSource, EventType, SyntheticEvent,
29};
30use azul_core::task::Instant;
31
32// `LocationFix` + `GeolocationProbeConfig` live in `azul-core` so
33// `NodeType::GeolocationProbe(GeolocationProbeConfig)` can reference
34// the config struct without a cyclic dep on `azul-layout`. We re-export
35// them here for the existing `azul_layout::managers::geolocation::*`
36// import paths.
37pub use azul_core::geolocation::{GeolocationProbeConfig, LocationFix};
38
39/// Diff event the layout pass emits when a probe appears or disappears.
40/// Symmetric to `PermissionDiffEvent` — drives the platform backend's
41/// native subscribe / release calls.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[repr(C, u8)]
44pub enum GeolocationDiffEvent {
45    /// First probe of this config landed in the layout — start a
46    /// native subscription with these options.
47    Subscribe { config: GeolocationProbeConfig },
48    /// Last probe left — stop the native subscription.
49    Release,
50    /// Probe config changed without subscriber churn — reconfigure
51    /// the running subscription in place (e.g. `high_accuracy` false →
52    /// true).
53    Reconfigure { config: GeolocationProbeConfig },
54}
55
56/// Cross-platform geolocation state. One per `App` (the OS gives us
57/// a single per-process subscription, not per-window).
58#[derive(Debug, Clone, PartialEq, Default)]
59pub struct GeolocationManager {
60    /// Most recent fix from the platform backend, or `None` until the
61    /// first native sample arrives (or `None` again after a Release).
62    pub latest_fix: Option<LocationFix>,
63    /// Active probe config — set on each Subscribe / Reconfigure,
64    /// cleared on Release.
65    pub active_config: Option<GeolocationProbeConfig>,
66    /// Diff queue drained once per frame by the platform backend.
67    pending_events: Vec<GeolocationDiffEvent>,
68    /// Refcount of `GeolocationProbe` nodes currently in the layout.
69    refcount: u32,
70    /// `true` when a fix advanced since the last event-pass drain. Set by
71    /// [`set_latest_fix`](Self::set_latest_fix), read by the `EventProvider`
72    /// impl (yields `EventType::GeolocationFix`), cleared by
73    /// [`clear_pending_event`](Self::clear_pending_event) after dispatch
74    /// (MWA-A1 — this manager previously computed fixes nobody dispatched).
75    pending_event: bool,
76    /// Most recent backend error (subscription failed / timed out /
77    /// revoked), or `None`. Cleared on the next successful fix (MWA-A1b).
78    pub last_error: Option<LocationError>,
79    /// `true` when an error arrived since the last event-pass drain — the
80    /// `EventProvider` impl yields `EventType::GeolocationError` for it.
81    pending_error_event: bool,
82}
83
84/// Error from the native geolocation backend (MWA-A1b).
85///
86/// Layout-internal
87/// (not FFI-exposed) until the `CallbackInfo` getter lands with the Phase C
88/// geolocation item — the `GeolocationError` EVENT carries no payload, so
89/// callbacks poll this via the manager.
90#[derive(Debug, Clone, PartialEq, Eq, Default)]
91pub struct LocationError {
92    /// Platform-specific error code (`CLError` code / `GError` code / HRESULT).
93    pub code: u32,
94    /// Human-readable message from the backend.
95    pub message: String,
96}
97
98impl GeolocationManager {
99    #[must_use] pub fn new() -> Self {
100        Self::default()
101    }
102
103    #[must_use] pub const fn latest_fix(&self) -> Option<LocationFix> {
104        self.latest_fix
105    }
106
107    #[must_use] pub const fn refcount(&self) -> u32 {
108        self.refcount
109    }
110
111    /// Platform backend writes the freshly-received fix. Returns true
112    /// if the fix actually advanced (different from the previous one)
113    /// so the caller can mark the window dirty for relayout.
114    ///
115    /// Compared via bit-pattern equality so missing fields (encoded as
116    /// `f32::NAN`) compare equal — `PartialEq` returns `false` on
117    /// NaN-vs-NaN, which would make every fix look "changed" even
118    /// when nothing actually moved.
119    pub fn set_latest_fix(&mut self, fix: LocationFix) -> bool {
120        let changed = self
121            .latest_fix
122            .is_none_or(|prev| !Self::location_fix_bitwise_eq(&prev, &fix));
123        self.latest_fix = Some(fix);
124        if changed {
125            self.pending_event = true;
126            // A successful fix supersedes any earlier error.
127            self.last_error = None;
128        }
129        changed
130    }
131
132    /// Platform backend reports a subscription error (MWA-A1b). Always
133    /// raises the error-event flag — repeated errors each answer a live
134    /// subscription and callbacks should hear every one.
135    pub fn set_last_error(&mut self, error: LocationError) {
136        self.last_error = Some(error);
137        self.pending_error_event = true;
138    }
139
140    /// Clear the pending-event flags (fix + error). The dll calls this
141    /// after the event pass has collected the geolocation events.
142    pub const fn clear_pending_event(&mut self) {
143        self.pending_event = false;
144        self.pending_error_event = false;
145    }
146
147    /// `true` while at least one `GeolocationProbe` is mounted — the
148    /// capability pump keeps its drain timer armed while this holds, so
149    /// fixes parked by the native backend reach callbacks without waiting
150    /// for unrelated input (MWA-A1 arming signal).
151    #[must_use] pub const fn has_active_subscription(&self) -> bool {
152        self.refcount > 0
153    }
154
155    const fn location_fix_bitwise_eq(a: &LocationFix, b: &LocationFix) -> bool {
156        a.latitude_deg.to_bits() == b.latitude_deg.to_bits()
157            && a.longitude_deg.to_bits() == b.longitude_deg.to_bits()
158            && a.accuracy_m.to_bits() == b.accuracy_m.to_bits()
159            && a.altitude_m.to_bits() == b.altitude_m.to_bits()
160            && a.altitude_accuracy_m.to_bits() == b.altitude_accuracy_m.to_bits()
161            && a.heading_deg.to_bits() == b.heading_deg.to_bits()
162            && a.speed_mps.to_bits() == b.speed_mps.to_bits()
163            && a.timestamp_ms == b.timestamp_ms
164    }
165
166    /// Drain queued diff events. Platform backend calls this once per
167    /// frame.
168    pub fn take_pending_events(&mut self) -> Vec<GeolocationDiffEvent> {
169        core::mem::take(&mut self.pending_events)
170    }
171
172    /// Diff entry point. The layout pass walks the styled DOM for
173    /// `GeolocationProbe` nodes and feeds each `(config, node_id)`
174    /// pair to the closure. The manager bumps the refcount, watches
175    /// for config drift, and enqueues the right Subscribe / Release /
176    /// Reconfigure event.
177    pub fn diff_layout<F>(&mut self, mut for_each_probe: F)
178    where
179        F: FnMut(&mut dyn FnMut(GeolocationProbeConfig)),
180    {
181        let mut new_count: u32 = 0;
182        let mut next_config: Option<GeolocationProbeConfig> = None;
183        for_each_probe(&mut |cfg| {
184            new_count += 1;
185            // First probe's config wins. Subsequent probes that
186            // disagree are accepted silently — a real app shouldn't
187            // mount two `GeolocationProbe`s with different configs
188            // but the framework can't assert that here.
189            if next_config.is_none() {
190                next_config = Some(cfg);
191            }
192        });
193
194        let old_count = self.refcount;
195        self.refcount = new_count;
196
197        match (old_count, new_count) {
198            (0, n) if n > 0 => {
199                let config = next_config.unwrap_or_default();
200                self.active_config = Some(config);
201                self.pending_events
202                    .push(GeolocationDiffEvent::Subscribe { config });
203            }
204            (m, 0) if m > 0 => {
205                self.active_config = None;
206                self.latest_fix = None;
207                self.pending_events.push(GeolocationDiffEvent::Release);
208            }
209            (m, n) if m > 0 && n > 0 => {
210                // Both frames have probes. Emit Reconfigure if the
211                // config actually drifted.
212                let new_config = next_config.unwrap_or_default();
213                if Some(new_config) != self.active_config {
214                    self.active_config = Some(new_config);
215                    self.pending_events
216                        .push(GeolocationDiffEvent::Reconfigure { config: new_config });
217                }
218            }
219            _ => {
220                // 0 → 0 — nothing to do.
221            }
222        }
223    }
224}
225
226impl EventProvider for GeolocationManager {
227    /// Yield a window-level `GeolocationFix` event when a fix advanced since
228    /// the last drain (target = root; read the value via
229    /// `CallbackInfo::get_geolocation_fix` inside the callback). Mirrors the
230    /// sensor / gamepad providers; before MWA-A1 nothing ever produced
231    /// `EventType::GeolocationFix`, so fix callbacks could never fire.
232    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent> {
233        let mut events = Vec::new();
234        if self.pending_event {
235            events.push(SyntheticEvent::new(
236                EventType::GeolocationFix,
237                CoreEventSource::User,
238                DomNodeId::ROOT,
239                timestamp.clone(),
240                EventData::None,
241            ));
242        }
243        if self.pending_error_event {
244            events.push(SyntheticEvent::new(
245                EventType::GeolocationError,
246                CoreEventSource::User,
247                DomNodeId::ROOT,
248                timestamp,
249                EventData::None,
250            ));
251        }
252        events
253    }
254}
255
256// ────────── Async fix channel (platform backend → manager) ────────────
257//
258// A native location callback (Android `FusedLocationProvider`
259// `onLocationResult`, iOS `CLLocationManagerDelegate`) fires on an
260// arbitrary thread with no handle to the live `GeolocationManager` (it
261// lives inside the window's `LayoutWindow`). The backend parks each fix
262// here; the layout pass drains it once per frame via
263// [`drain_location_fixes`] and applies the latest through
264// [`GeolocationManager::set_latest_fix`]. Pure Rust — no platform
265// dependency (SUPER_PLAN_2 §0.5). Mirrors the permission manager's
266// async-result channel.
267
268static PENDING_FIXES: std::sync::Mutex<Vec<LocationFix>> =
269    std::sync::Mutex::new(Vec::new());
270
271/// Park a location fix delivered by a platform backend (in the dll).
272/// Thread-safe; recovers from a poisoned lock so one panicking applier
273/// can't wedge delivery forever.
274pub fn push_location_fix(fix: LocationFix) {
275    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
276    q.push(fix);
277}
278
279/// Drain every fix parked by [`push_location_fix`], in arrival order.
280/// Called once per layout pass; the caller applies them through
281/// [`GeolocationManager::set_latest_fix`] (the last one wins).
282pub fn drain_location_fixes() -> Vec<LocationFix> {
283    let mut q = PENDING_FIXES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
284    core::mem::take(&mut *q)
285}
286
287// Error channel (MWA-A1b) — same shape as the fix channel: the native
288// backend's error callback fires on an OS thread and parks here; the
289// capability pump drains into `set_last_error`.
290
291static PENDING_ERRORS: std::sync::Mutex<Vec<LocationError>> =
292    std::sync::Mutex::new(Vec::new());
293
294/// Park a geolocation error delivered by a platform backend (in the dll).
295/// Thread-safe; poison-recovering.
296pub fn push_location_error(error: LocationError) {
297    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
298    q.push(error);
299}
300
301/// Drain every error parked by [`push_location_error`], in arrival order.
302pub fn drain_location_errors() -> Vec<LocationError> {
303    let mut q = PENDING_ERRORS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
304    core::mem::take(&mut *q)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn cfg() -> GeolocationProbeConfig {
312        GeolocationProbeConfig::default()
313    }
314
315    fn high_accuracy_cfg() -> GeolocationProbeConfig {
316        GeolocationProbeConfig {
317            high_accuracy: true,
318            ..GeolocationProbeConfig::default()
319        }
320    }
321
322    fn fix(lat: f64, lon: f64) -> LocationFix {
323        LocationFix {
324            latitude_deg: lat,
325            longitude_deg: lon,
326            accuracy_m: 10.0,
327            altitude_m: f32::NAN,
328            altitude_accuracy_m: f32::NAN,
329            heading_deg: f32::NAN,
330            speed_mps: f32::NAN,
331            timestamp_ms: 0,
332        }
333    }
334
335    #[test]
336    fn first_probe_emits_subscribe_with_config() {
337        let mut mgr = GeolocationManager::new();
338        mgr.diff_layout(|emit| emit(cfg()));
339        assert_eq!(mgr.refcount(), 1);
340        let events = mgr.take_pending_events();
341        assert_eq!(events.len(), 1);
342        assert!(matches!(events[0], GeolocationDiffEvent::Subscribe { .. }));
343    }
344
345    #[test]
346    fn last_probe_drop_emits_release_and_clears_fix() {
347        let mut mgr = GeolocationManager::new();
348        mgr.diff_layout(|emit| emit(cfg()));
349        mgr.set_latest_fix(fix(37.0, -122.0));
350        drop(mgr.take_pending_events());
351
352        mgr.diff_layout(|_emit| {});
353        assert_eq!(mgr.refcount(), 0);
354        assert_eq!(mgr.latest_fix(), None);
355        let events = mgr.take_pending_events();
356        assert_eq!(events.len(), 1);
357        assert!(matches!(events[0], GeolocationDiffEvent::Release));
358    }
359
360    #[test]
361    fn config_drift_emits_reconfigure() {
362        let mut mgr = GeolocationManager::new();
363        mgr.diff_layout(|emit| emit(cfg()));
364        drop(mgr.take_pending_events());
365
366        mgr.diff_layout(|emit| emit(high_accuracy_cfg()));
367        let events = mgr.take_pending_events();
368        assert_eq!(events.len(), 1);
369        let ev = &events[0];
370        match ev {
371            GeolocationDiffEvent::Reconfigure { config } => {
372                assert!(config.high_accuracy);
373            }
374            _ => panic!("expected Reconfigure, got {ev:?}"),
375        }
376    }
377
378    #[test]
379    fn stable_config_does_not_re_emit() {
380        let mut mgr = GeolocationManager::new();
381        mgr.diff_layout(|emit| emit(cfg()));
382        drop(mgr.take_pending_events());
383
384        // Same config across frames — no events.
385        mgr.diff_layout(|emit| emit(cfg()));
386        assert!(mgr.take_pending_events().is_empty());
387    }
388
389    #[test]
390    fn set_latest_fix_returns_change_flag() {
391        let mut mgr = GeolocationManager::new();
392        assert!(mgr.set_latest_fix(fix(37.0, -122.0)));
393        assert!(!mgr.set_latest_fix(fix(37.0, -122.0)));
394        assert!(mgr.set_latest_fix(fix(37.7749, -122.4194)));
395    }
396
397    #[test]
398    fn missing_fields_decode_to_none() {
399        let f = fix(0.0, 0.0);
400        assert_eq!(f.altitude(), None);
401        assert_eq!(f.heading(), None);
402        assert_eq!(f.speed(), None);
403    }
404
405    #[test]
406    fn provider_yields_fix_event_then_clears() {
407        use azul_core::task::{Instant, SystemTick};
408
409        let ts = Instant::Tick(SystemTick::new(0));
410        let mut mgr = GeolocationManager::new();
411        assert!(mgr.get_pending_events(ts.clone()).is_empty(), "no fix yet");
412
413        mgr.set_latest_fix(fix(37.0, -122.0));
414        let events = mgr.get_pending_events(ts.clone());
415        assert_eq!(events.len(), 1);
416        assert_eq!(events[0].event_type, EventType::GeolocationFix);
417
418        mgr.clear_pending_event();
419        assert!(mgr.get_pending_events(ts.clone()).is_empty(), "cleared after dispatch");
420
421        // An identical fix is not a change — no re-fire.
422        mgr.set_latest_fix(fix(37.0, -122.0));
423        assert!(mgr.get_pending_events(ts).is_empty());
424    }
425
426    #[test]
427    fn error_channel_and_provider_event() {
428        use azul_core::task::{Instant, SystemTick};
429
430        drop(drain_location_errors());
431        push_location_error(LocationError { code: 1, message: "denied".into() });
432        let errs = drain_location_errors();
433        assert_eq!(errs.len(), 1);
434        assert!(drain_location_errors().is_empty());
435
436        let ts = Instant::Tick(SystemTick::new(0));
437        let mut mgr = GeolocationManager::new();
438        mgr.set_last_error(errs[0].clone());
439        let events = mgr.get_pending_events(ts.clone());
440        assert_eq!(events.len(), 1);
441        assert_eq!(
442            events[0].event_type,
443            EventType::GeolocationError
444        );
445        mgr.clear_pending_event();
446        assert!(mgr.get_pending_events(ts).is_empty());
447
448        // a successful (changed) fix supersedes the stored error
449        mgr.set_latest_fix(fix(1.0, 2.0));
450        assert!(mgr.last_error.is_none());
451    }
452
453    #[test]
454    fn subscription_flag_follows_probe_refcount() {
455        let mut mgr = GeolocationManager::new();
456        assert!(!mgr.has_active_subscription());
457        mgr.diff_layout(|emit| emit(cfg()));
458        assert!(mgr.has_active_subscription());
459        mgr.diff_layout(|_emit| {});
460        assert!(!mgr.has_active_subscription());
461    }
462
463    #[test]
464    #[allow(clippy::float_cmp)] // test asserts exact float equality on deterministic values
465    fn async_fixes_round_trip_through_manager() {
466        // The channel is process-global; clear any residue first.
467        drop(drain_location_fixes());
468
469        push_location_fix(fix(37.0, -122.0));
470        push_location_fix(fix(48.8566, 2.3522)); // Paris — last wins
471        let drained = drain_location_fixes();
472        assert_eq!(drained.len(), 2, "both parked fixes drain in order");
473        assert_eq!(drained[0].latitude_deg, 37.0);
474        assert_eq!(drained[1].latitude_deg, 48.8566);
475
476        // Applying them reflects in latest_fix() — what the layout pass does.
477        let mut mgr = GeolocationManager::new();
478        for f in &drained {
479            mgr.set_latest_fix(*f);
480        }
481        let got = mgr.latest_fix().expect("a fix was applied");
482        assert_eq!(got.latitude_deg, 48.8566, "the last applied fix wins");
483
484        // A second drain is empty — the queue was taken, not copied.
485        assert!(drain_location_fixes().is_empty());
486    }
487}