Skip to main content

ferroui_android/platform/
android_insets_manager.rs

1//! The insets of the window of an activity: the safe area, the system bars
2//! and the input pane.
3//!
4//! The reference calls the window and the compatibility classes of
5//! AndroidX inline. Here the calls are behind [`IInsetsWindow`], which the
6//! Java layer answers with the classes of the platform
7//! (docs/porting/android-platform.md, section 3.3) and the tests with a
8//! fake; the logic of the manager is the same on both.
9
10use ferroui_base::animation::easings::IEasing;
11use ferroui_base::media::{Color, Colors};
12use ferroui_base::reactive::IDisposable;
13use ferroui_base::{Rect, Size, Thickness};
14use ferroui_controls::platform::{
15    IInputPane, IInsetsManager, InputPaneBase, InputPaneState, InputPaneStateEventArgs, InsetsManagerBase,
16    SafeAreaChangedArgs, SystemBarTheme,
17};
18use std::cell::Cell;
19use std::rc::{Rc, Weak};
20use std::time::Duration;
21
22/// The insets of the root window, in pixels.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub(crate) struct RootInsets {
25    /// The status bars, the navigation bars and the display cutout together.
26    pub left: i32,
27    pub top: i32,
28    pub right: i32,
29    pub bottom: i32,
30    /// The bottom inset of the navigation bars.
31    pub navigation_bars_bottom: i32,
32    /// The bottom inset of the input method.
33    pub ime_bottom: i32,
34    pub ime_visible: bool,
35    /// Whether the status and navigation bars are visible.
36    pub system_bars_visible: bool,
37}
38
39impl RootInsets {
40    /// Reads the values of `PlatformHelper.getRootInsets`.
41    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
42    pub fn from_array(values: &[i32]) -> Option<RootInsets> {
43        match values {
44            [left, top, right, bottom, navigation_bars_bottom, ime_bottom, ime_visible, system_bars_visible] => {
45                Some(RootInsets {
46                    left: *left,
47                    top: *top,
48                    right: *right,
49                    bottom: *bottom,
50                    navigation_bars_bottom: *navigation_bars_bottom,
51                    ime_bottom: *ime_bottom,
52                    ime_visible: *ime_visible != 0,
53                    system_bars_visible: *system_bars_visible != 0,
54                })
55            }
56            _ => None,
57        }
58    }
59}
60
61/// The window of the activity, as the insets manager uses it.
62pub(crate) trait IInsetsWindow {
63    /// `Build.VERSION.SDK_INT`.
64    fn sdk_int(&self) -> i32;
65
66    /// Whether the activity has a window.
67    fn has_window(&self) -> bool;
68
69    /// The insets of the root window; `None` before the window has any.
70    fn root_insets(&self) -> Option<RootInsets>;
71
72    /// Whether the system displays the application edge to edge whatever it
73    /// asks for: it targets API 35 and runs on API 35 or later.
74    fn is_display_edge_to_edge_forced(&self) -> bool;
75
76    fn set_layout_in_display_cutout_mode(&self, short_edges: bool);
77    fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool);
78    /// Adds the translucent flags of the status and the navigation bar.
79    fn add_translucent_bars(&self);
80    /// Clears the translucent flags, draws the bar backgrounds and, where
81    /// the system still allows it, sets their colour.
82    fn set_system_bar_color(&self, color: Color);
83    fn set_navigation_bar_contrast_enforced(&self, enforced: bool);
84    /// Whether the status bar is drawn for a light background; light when
85    /// the window cannot be asked.
86    fn appearance_light_status_bars(&self) -> bool;
87    fn set_appearance_light_bars(&self, light: bool);
88    fn set_system_bars_visible(&self, visible: bool);
89}
90
91/// The top-level, as the insets manager uses it.
92pub(crate) trait IInsetsTopLevel {
93    /// `RenderScaling` of the top-level.
94    fn top_level_render_scaling(&self) -> f64;
95    /// `ClientSize` of the top-level.
96    fn top_level_client_size(&self) -> Size;
97}
98
99pub struct AndroidInsetsManager {
100    window: Box<dyn IInsetsWindow>,
101    top_level: Weak<dyn IInsetsTopLevel>,
102    displays_edge_to_edge: Cell<bool>,
103    system_ui_visibility: Cell<Option<bool>>,
104    status_bar_theme: Cell<Option<SystemBarTheme>>,
105    is_default_system_bar_light_theme: Cell<Option<bool>>,
106    system_bar_color: Cell<Option<Color>>,
107    state: Cell<InputPaneState>,
108    previous_rect: Cell<Rect>,
109    previous_ime_inset: Cell<Option<i32>>,
110    display_edge_to_edge_preference: Cell<bool>,
111    uses_legacy_layouts: bool,
112    is_display_edge_to_edge_forced: bool,
113    safe_area_changed: InsetsManagerBase,
114    state_changed: InputPaneBase,
115}
116
117/// Android 10 (`Build.VERSION_CODES.Q`).
118const Q: i32 = 29;
119/// Android 11 (`Build.VERSION_CODES.R`).
120const R: i32 = 30;
121
122impl AndroidInsetsManager {
123    /// Creates the manager of the window. The caller makes the window
124    /// report applied insets to
125    /// [`on_apply_window_insets`](Self::on_apply_window_insets).
126    ///
127    /// Below API 30 the window also reports the global layout of its decor
128    /// view ([`on_global_layout`](Self::on_global_layout)), from API 30 the
129    /// start of an animation of its insets
130    /// ([`on_insets_animation_start`](Self::on_insets_animation_start)).
131    pub(crate) fn new(
132        window: Box<dyn IInsetsWindow>,
133        top_level: Weak<dyn IInsetsTopLevel>,
134    ) -> Rc<AndroidInsetsManager> {
135        // Better detection for target sdk and running api level. Apps can change their target sdk and bypass
136        // the fixed target sdk level.
137        let is_display_edge_to_edge_forced = window.is_display_edge_to_edge_forced();
138        let uses_legacy_layouts = window.sdk_int() < R;
139
140        let this = Rc::new(AndroidInsetsManager {
141            window,
142            top_level,
143            displays_edge_to_edge: Cell::new(false),
144            system_ui_visibility: Cell::new(None),
145            status_bar_theme: Cell::new(None),
146            is_default_system_bar_light_theme: Cell::new(None),
147            system_bar_color: Cell::new(None),
148            state: Cell::new(InputPaneState::Closed),
149            previous_rect: Cell::new(Rect::default()),
150            previous_ime_inset: Cell::new(None),
151            display_edge_to_edge_preference: Cell::new(false),
152            uses_legacy_layouts,
153            is_display_edge_to_edge_forced,
154            safe_area_changed: InsetsManagerBase::new(),
155            state_changed: InputPaneBase::new(),
156        });
157
158        this.set_display_edge_to_edge_preference(false);
159        this
160    }
161
162    fn render_scaling(&self) -> f64 {
163        self.top_level.upgrade().map_or(1.0, |top_level| top_level.top_level_render_scaling())
164    }
165
166    fn client_size(&self) -> Size {
167        self.top_level.upgrade().map_or(Size::default(), |top_level| top_level.top_level_client_size())
168    }
169
170    fn set_state(&self, value: InputPaneState) {
171        let old_state = self.state.replace(value);
172
173        if old_state != value && self.window.sdk_int() <= Q {
174            let current_rect = self.occluded_rect();
175            self.notify_state_changed(value, Some(self.previous_rect.get()), current_rect, Duration::ZERO, None);
176            self.previous_rect.set(current_rect);
177        }
178    }
179
180    fn update_display_edge_to_egde_state(&self) {
181        if self.is_display_edge_to_edge_forced {
182            self.displays_edge_to_edge.set(true);
183            return;
184        }
185
186        let preference = self.display_edge_to_edge_preference.get();
187        self.displays_edge_to_edge.set(preference);
188
189        self.window.set_layout_in_display_cutout_mode(preference);
190        self.window.set_decor_fits_system_windows(!preference);
191
192        if preference {
193            self.window.add_translucent_bars();
194        } else {
195            self.set_system_bar_color(self.system_bar_color.get());
196        }
197    }
198
199    /// The window applied insets: `ime_visible` and `ime_bottom` are those
200    /// of the insets that were applied.
201    pub(crate) fn on_apply_window_insets(&self, has_insets: bool, ime_visible: bool, ime_bottom: i32) {
202        self.notify_safe_area_changed(self.safe_area_padding());
203
204        if self.previous_rect.get() == Rect::default() {
205            self.previous_rect.set(self.occluded_rect());
206        }
207
208        self.set_state(if has_insets && ime_visible { InputPaneState::Open } else { InputPaneState::Closed });
209
210        // Workaround for weird inset values for android 11
211        if self.window.sdk_int() == R {
212            let ime_inset = has_insets.then_some(ime_bottom);
213            if self.previous_ime_inset.get().is_none() {
214                self.previous_ime_inset.set(ime_inset);
215            }
216            if ime_inset.unwrap_or(0) != self.previous_ime_inset.get().unwrap_or(0) {
217                self.notify_state_changed(
218                    self.state.get(),
219                    Some(self.previous_rect.get()),
220                    self.occluded_rect(),
221                    Duration::ZERO,
222                    None,
223                );
224            }
225            self.previous_ime_inset.set(ime_inset);
226        }
227    }
228
229    pub(crate) fn set_default_system_light_mode(&self, is_light_mode: bool) {
230        self.is_default_system_bar_light_theme.set(Some(is_light_mode));
231    }
232
233    fn notify_safe_area_changed(&self, safe_area_padding: Thickness) {
234        self.safe_area_changed.on_safe_area_changed(SafeAreaChangedArgs::new(safe_area_padding));
235    }
236
237    fn notify_state_changed(
238        &self,
239        new_state: InputPaneState,
240        start_rect: Option<Rect>,
241        end_rect: Rect,
242        animation_duration: Duration,
243        easing: Option<Rc<dyn IEasing>>,
244    ) {
245        self.state_changed.on_state_changed(InputPaneStateEventArgs::with_animation(
246            new_state,
247            start_rect,
248            end_rect,
249            animation_duration,
250            easing,
251        ));
252    }
253
254    /// The layout of the view tree of the decor view changed (reported
255    /// below API 30).
256    pub(crate) fn on_global_layout(&self) {
257        self.notify_safe_area_changed(self.safe_area_padding());
258
259        if self.uses_legacy_layouts {
260            let insets = self.window.root_insets();
261            self.set_state(if insets.is_some_and(|insets| insets.ime_visible) {
262                InputPaneState::Open
263            } else {
264                InputPaneState::Closed
265            });
266        }
267    }
268
269    /// An animation of the insets of the input method starts
270    /// (`WindowInsetsAnimation.Callback.onStart`, for an animation whose
271    /// type mask has the input method): the bottom of the lower and of the
272    /// upper bound of the animation in pixels, its duration and its
273    /// interpolator.
274    pub(crate) fn on_insets_animation_start(
275        &self,
276        lower_bound_bottom: i32,
277        upper_bound_bottom: i32,
278        duration_millis: i64,
279        easing: Option<Rc<dyn IEasing>>,
280    ) {
281        if let Some(insets) = self.window.root_insets() {
282            let navbar_inset = insets.navigation_bars_bottom;
283            let client_size = self.client_size();
284            let bottom = self.safe_area_padding().bottom;
285            let rect_of = |bound_bottom: i32| {
286                let height = f64::from((((bound_bottom - navbar_inset) as f64 / self.render_scaling()) as f32).max(0.0));
287                Rect::new(0.0, client_size.height - bottom - height, client_size.width, height)
288            };
289            let upper_rect = rect_of(lower_bound_bottom);
290            let lower_rect = rect_of(upper_bound_bottom);
291
292            let duration = Duration::from_millis(u64::try_from(duration_millis).unwrap_or(0));
293
294            let is_opening = self.state.get() == InputPaneState::Open;
295            self.notify_state_changed(
296                self.state.get(),
297                Some(if is_opening { upper_rect } else { lower_rect }),
298                if is_opening { lower_rect } else { upper_rect },
299                duration,
300                easing,
301            );
302        }
303    }
304
305    /// The theme of the system bars.
306    pub fn system_bar_theme(&self) -> Option<SystemBarTheme> {
307        Some(if self.window.appearance_light_status_bars() { SystemBarTheme::Light } else { SystemBarTheme::Dark })
308    }
309
310    /// Sets the theme of the system bars; `None` is the theme of the
311    /// system.
312    pub fn set_system_bar_theme(&self, value: Option<SystemBarTheme>) {
313        self.status_bar_theme.set(value);
314
315        if self.is_default_system_bar_light_theme.get().is_none() {
316            self.is_default_system_bar_light_theme.set(Some(self.window.appearance_light_status_bars()));
317        }
318
319        let value = value.unwrap_or(if self.is_default_system_bar_light_theme.get() == Some(true) {
320            SystemBarTheme::Light
321        } else {
322            SystemBarTheme::Dark
323        });
324
325        self.window.set_appearance_light_bars(value == SystemBarTheme::Light);
326    }
327
328    pub(crate) fn apply_status_bar_state(&self) {
329        self.set_is_system_bar_visible(self.system_ui_visibility.get());
330        self.set_system_bar_theme(self.status_bar_theme.get());
331        self.set_system_bar_color(self.system_bar_color.get());
332    }
333}
334
335/// The safe area of insets at a scaling: the bars and the cutout when the
336/// window is displayed edge to edge, nothing otherwise.
337pub(crate) fn safe_area_padding_of(insets: &RootInsets, displays_edge_to_edge: bool, render_scaling: f64) -> Thickness {
338    if !displays_edge_to_edge {
339        return Thickness::default();
340    }
341    Thickness::new(
342        f64::from(insets.left) / render_scaling,
343        f64::from(insets.top) / render_scaling,
344        f64::from(insets.right) / render_scaling,
345        f64::from(insets.bottom) / render_scaling,
346    )
347}
348
349/// The rectangle of the client area the input method covers.
350pub(crate) fn occluded_rect_of(
351    insets: &RootInsets,
352    render_scaling: f64,
353    client_size: Size,
354    safe_area_bottom: f64,
355) -> Rect {
356    let navbar_inset = insets.navigation_bars_bottom;
357    let ime_inset = insets.ime_bottom;
358
359    let height = f64::from((((ime_inset - navbar_inset) as f64 / render_scaling) as f32).max(0.0));
360
361    Rect::new(0.0, client_size.height - safe_area_bottom - height, client_size.width, height)
362}
363
364impl IInsetsManager for AndroidInsetsManager {
365    fn is_system_bar_visible(&self) -> Option<bool> {
366        if !self.window.has_window() {
367            return Some(true);
368        }
369        self.window.root_insets().map(|insets| insets.system_bars_visible)
370    }
371
372    fn set_is_system_bar_visible(&self, value: Option<bool>) {
373        self.system_ui_visibility.set(value);
374
375        self.window.set_system_bars_visible(value.unwrap_or(true));
376    }
377
378    fn display_edge_to_edge_preference(&self) -> bool {
379        self.display_edge_to_edge_preference.get()
380    }
381
382    fn set_display_edge_to_edge_preference(&self, value: bool) {
383        self.display_edge_to_edge_preference.set(value);
384
385        self.update_display_edge_to_egde_state();
386    }
387
388    fn displays_edge_to_edge(&self) -> bool {
389        self.displays_edge_to_edge.get()
390    }
391
392    fn safe_area_padding(&self) -> Thickness {
393        match self.window.root_insets() {
394            Some(insets) => safe_area_padding_of(&insets, self.displays_edge_to_edge(), self.render_scaling()),
395            None => Thickness::default(),
396        }
397    }
398
399    fn system_bar_color(&self) -> Option<Color> {
400        self.system_bar_color.get()
401    }
402
403    fn set_system_bar_color(&self, value: Option<Color>) {
404        self.system_bar_color.set(value);
405
406        if self.is_display_edge_to_edge_forced {
407            // Allow having fully transparent navbars when on api level 35
408            if self.window.sdk_int() >= 36 {
409                self.window.set_navigation_bar_contrast_enforced(false);
410            } else if self.window.sdk_int() >= 35 {
411                self.window.set_navigation_bar_contrast_enforced(value != Some(Colors::TRANSPARENT));
412            }
413            return;
414        }
415
416        if let Some(color) = value {
417            if !self.displays_edge_to_edge.get() && self.window.has_window() {
418                self.window.set_system_bar_color(color);
419            }
420        }
421    }
422
423    fn safe_area_changed(&self, handler: Rc<dyn Fn(&SafeAreaChangedArgs)>) -> Rc<dyn IDisposable> {
424        self.safe_area_changed.safe_area_changed(handler)
425    }
426}
427
428impl IInputPane for AndroidInsetsManager {
429    fn state(&self) -> InputPaneState {
430        self.state.get()
431    }
432
433    fn occluded_rect(&self) -> Rect {
434        match self.window.root_insets() {
435            Some(insets) => {
436                occluded_rect_of(&insets, self.render_scaling(), self.client_size(), self.safe_area_padding().bottom)
437            }
438            None => Rect::default(),
439        }
440    }
441
442    fn state_changed(&self, handler: Rc<dyn Fn(&InputPaneStateEventArgs)>) -> Rc<dyn IDisposable> {
443        self.state_changed.state_changed(handler)
444    }
445}
446
447#[cfg(target_os = "android")]
448pub(crate) use imp::{ActivityInsetsWindow, AnimationEasing};
449
450#[cfg(target_os = "android")]
451mod imp {
452    use super::{IInsetsWindow, RootInsets};
453    use crate::interop::java::{
454        call_object, call_static_boolean, call_static_object, call_static_void, int_array_of, JavaClass, JavaObject,
455        JavaValue,
456    };
457    use crate::interop::natives::{sdk_int, PLATFORM_HELPER};
458    use ferroui_base::animation::easings::{IEasing, SharedEasing};
459    use ferroui_base::media::Color;
460    use std::sync::Arc;
461
462    /// The interpolator of an animation of the system as an easing
463    /// (`AnimationEasing` of the reference).
464    pub(crate) struct AnimationEasing {
465        interpolator: JavaObject,
466    }
467
468    impl AnimationEasing {
469        pub fn new(interpolator: JavaObject) -> Self {
470            Self { interpolator }
471        }
472    }
473
474    impl IEasing for AnimationEasing {
475        fn ease(&self, progress: f64) -> f64 {
476            f64::from(crate::interop::java::call_float(
477                &self.interpolator,
478                "getInterpolation",
479                "(F)F",
480                &[JavaValue::Float(progress as f32)],
481            ))
482        }
483
484        fn to_shared(&self) -> Arc<SharedEasing> {
485            Arc::new(AnimationEasing { interpolator: self.interpolator.clone() })
486        }
487    }
488
489    /// The window of an activity, asked through the Java layer.
490    pub(crate) struct ActivityInsetsWindow {
491        activity: JavaObject,
492    }
493
494    impl ActivityInsetsWindow {
495        /// The window of `activity`, which reports the insets it applies to
496        /// the insets manager registered under `handle`.
497        pub fn new(activity: JavaObject, handle: i64) -> Self {
498            call_static_void(
499                &JavaClass::find(PLATFORM_HELPER),
500                "setInsetsListener",
501                "(Landroid/app/Activity;J)V",
502                &[JavaValue::Object(Some(&activity)), JavaValue::Long(handle)],
503            );
504            Self { activity }
505        }
506
507        fn call(&self, name: &str, value: bool) {
508            call_static_void(
509                &JavaClass::find(PLATFORM_HELPER),
510                name,
511                "(Landroid/app/Activity;Z)V",
512                &[JavaValue::Object(Some(&self.activity)), JavaValue::Boolean(value)],
513            );
514        }
515    }
516
517    impl IInsetsWindow for ActivityInsetsWindow {
518        fn sdk_int(&self) -> i32 {
519            sdk_int()
520        }
521
522        fn has_window(&self) -> bool {
523            call_object(&self.activity, "getWindow", "()Landroid/view/Window;", &[]).is_some()
524        }
525
526        fn root_insets(&self) -> Option<RootInsets> {
527            let values = call_static_object(
528                &JavaClass::find(PLATFORM_HELPER),
529                "getRootInsets",
530                "(Landroid/app/Activity;)[I",
531                &[JavaValue::Object(Some(&self.activity))],
532            )?;
533            RootInsets::from_array(&int_array_of(&values))
534        }
535
536        fn is_display_edge_to_edge_forced(&self) -> bool {
537            call_static_boolean(
538                &JavaClass::find(PLATFORM_HELPER),
539                "isDisplayEdgeToEdgeForced",
540                "(Landroid/app/Activity;)Z",
541                &[JavaValue::Object(Some(&self.activity))],
542            )
543        }
544
545        fn set_layout_in_display_cutout_mode(&self, short_edges: bool) {
546            self.call("setLayoutInDisplayCutoutMode", short_edges);
547        }
548
549        fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool) {
550            self.call("setDecorFitsSystemWindows", decor_fits_system_windows);
551        }
552
553        fn add_translucent_bars(&self) {
554            self.call("setTranslucentBars", true);
555        }
556
557        fn set_system_bar_color(&self, color: Color) {
558            call_static_void(
559                &JavaClass::find(PLATFORM_HELPER),
560                "setSystemBarColor",
561                "(Landroid/app/Activity;I)V",
562                &[JavaValue::Object(Some(&self.activity)), JavaValue::Int(color.to_uint32() as i32)],
563            );
564        }
565
566        fn set_navigation_bar_contrast_enforced(&self, enforced: bool) {
567            self.call("setNavigationBarContrastEnforced", enforced);
568        }
569
570        fn appearance_light_status_bars(&self) -> bool {
571            call_static_boolean(
572                &JavaClass::find(PLATFORM_HELPER),
573                "getAppearanceLightStatusBars",
574                "(Landroid/app/Activity;)Z",
575                &[JavaValue::Object(Some(&self.activity))],
576            )
577        }
578
579        fn set_appearance_light_bars(&self, light: bool) {
580            self.call("setAppearanceLightBars", light);
581        }
582
583        fn set_system_bars_visible(&self, visible: bool) {
584            self.call("setSystemBarsVisible", visible);
585        }
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    // Not from the reference, which has no tests of the insets manager.
592    use super::*;
593    use std::cell::RefCell;
594
595    #[derive(Default)]
596    struct WindowState {
597        sdk_int: Cell<i32>,
598        forced: Cell<bool>,
599        insets: Cell<Option<RootInsets>>,
600        light_status_bars: Cell<bool>,
601        calls: RefCell<Vec<String>>,
602    }
603
604    struct Window(Rc<WindowState>);
605
606    impl IInsetsWindow for Window {
607        fn sdk_int(&self) -> i32 {
608            self.0.sdk_int.get()
609        }
610
611        fn has_window(&self) -> bool {
612            true
613        }
614
615        fn root_insets(&self) -> Option<RootInsets> {
616            self.0.insets.get()
617        }
618
619        fn is_display_edge_to_edge_forced(&self) -> bool {
620            self.0.forced.get()
621        }
622
623        fn set_layout_in_display_cutout_mode(&self, short_edges: bool) {
624            self.0.calls.borrow_mut().push(format!("cutout {short_edges}"));
625        }
626
627        fn set_decor_fits_system_windows(&self, decor_fits_system_windows: bool) {
628            self.0.calls.borrow_mut().push(format!("fits {decor_fits_system_windows}"));
629        }
630
631        fn add_translucent_bars(&self) {
632            self.0.calls.borrow_mut().push("translucent".to_string());
633        }
634
635        fn set_system_bar_color(&self, color: Color) {
636            self.0.calls.borrow_mut().push(format!("color {:08x}", color.to_uint32()));
637        }
638
639        fn set_navigation_bar_contrast_enforced(&self, enforced: bool) {
640            self.0.calls.borrow_mut().push(format!("contrast {enforced}"));
641        }
642
643        fn appearance_light_status_bars(&self) -> bool {
644            self.0.light_status_bars.get()
645        }
646
647        fn set_appearance_light_bars(&self, light: bool) {
648            self.0.light_status_bars.set(light);
649            self.0.calls.borrow_mut().push(format!("light {light}"));
650        }
651
652        fn set_system_bars_visible(&self, visible: bool) {
653            self.0.calls.borrow_mut().push(format!("visible {visible}"));
654        }
655    }
656
657    struct TopLevel;
658
659    impl IInsetsTopLevel for TopLevel {
660        fn top_level_render_scaling(&self) -> f64 {
661            2.0
662        }
663
664        fn top_level_client_size(&self) -> Size {
665            Size::new(540.0, 1200.0)
666        }
667    }
668
669    fn manager(sdk_int: i32, forced: bool) -> (Rc<AndroidInsetsManager>, Rc<WindowState>, Rc<dyn IInsetsTopLevel>) {
670        let state = Rc::new(WindowState::default());
671        state.sdk_int.set(sdk_int);
672        state.forced.set(forced);
673        let top_level: Rc<dyn IInsetsTopLevel> = Rc::new(TopLevel);
674        let manager = AndroidInsetsManager::new(Box::new(Window(state.clone())), Rc::downgrade(&top_level));
675        (manager, state, top_level)
676    }
677
678    const BARS: RootInsets = RootInsets {
679        left: 0,
680        top: 100,
681        right: 0,
682        bottom: 48,
683        navigation_bars_bottom: 48,
684        ime_bottom: 0,
685        ime_visible: false,
686        system_bars_visible: true,
687    };
688
689    #[test]
690    fn the_values_of_the_java_layer_are_read() {
691        assert_eq!(RootInsets::from_array(&[0, 100, 0, 48, 48, 0, 0, 1]), Some(BARS));
692        assert_eq!(RootInsets::from_array(&[0, 100]), None);
693    }
694
695    #[test]
696    fn a_window_that_is_not_edge_to_edge_has_no_safe_area() {
697        let (manager, state, _top_level) = manager(34, false);
698        state.insets.set(Some(BARS));
699
700        assert!(!manager.displays_edge_to_edge());
701        assert_eq!(manager.safe_area_padding(), Thickness::default());
702        // The constructor applied the preference: no cutout, the decor fits.
703        assert_eq!(*state.calls.borrow(), ["cutout false", "fits true"]);
704    }
705
706    #[test]
707    fn the_preference_displays_edge_to_edge_and_the_safe_area_is_the_bars() {
708        let (manager, state, _top_level) = manager(34, false);
709        state.insets.set(Some(BARS));
710        state.calls.borrow_mut().clear();
711
712        manager.set_display_edge_to_edge_preference(true);
713
714        assert!(manager.displays_edge_to_edge());
715        assert_eq!(manager.safe_area_padding(), Thickness::new(0.0, 50.0, 0.0, 24.0));
716        assert_eq!(*state.calls.borrow(), ["cutout true", "fits false", "translucent"]);
717    }
718
719    #[test]
720    fn a_system_that_forces_edge_to_edge_ignores_the_preference() {
721        let (manager, state, _top_level) = manager(36, true);
722        state.insets.set(Some(BARS));
723
724        assert!(manager.displays_edge_to_edge());
725        assert!(!manager.display_edge_to_edge_preference());
726        assert_eq!(manager.safe_area_padding(), Thickness::new(0.0, 50.0, 0.0, 24.0));
727        assert!(state.calls.borrow().is_empty());
728
729        // The colour of the bars cannot change; the contrast of the navigation bar is not enforced.
730        manager.set_system_bar_color(Some(Color::from_argb(255, 1, 2, 3)));
731        assert_eq!(manager.system_bar_color(), Some(Color::from_argb(255, 1, 2, 3)));
732        assert_eq!(*state.calls.borrow(), ["contrast false"]);
733    }
734
735    #[test]
736    fn on_api_35_the_contrast_is_enforced_unless_the_colour_is_transparent() {
737        let (manager, state, _top_level) = manager(35, true);
738
739        manager.set_system_bar_color(Some(Colors::TRANSPARENT));
740        manager.set_system_bar_color(None);
741
742        assert_eq!(*state.calls.borrow(), ["contrast false", "contrast true"]);
743    }
744
745    #[test]
746    fn a_colour_is_set_on_a_window_that_is_not_edge_to_edge() {
747        let (manager, state, _top_level) = manager(34, false);
748        state.calls.borrow_mut().clear();
749
750        manager.set_system_bar_color(Some(Color::from_argb(255, 0x10, 0x20, 0x30)));
751        assert_eq!(*state.calls.borrow(), ["color ff102030"]);
752
753        manager.set_display_edge_to_edge_preference(true);
754        state.calls.borrow_mut().clear();
755        manager.set_system_bar_color(Some(Color::from_argb(255, 1, 1, 1)));
756        assert!(state.calls.borrow().is_empty());
757    }
758
759    #[test]
760    fn the_occluded_rectangle_is_the_input_method_above_the_navigation_bar() {
761        let (manager, state, _top_level) = manager(36, true);
762        let mut insets = BARS;
763        insets.ime_bottom = 848;
764        insets.ime_visible = true;
765        insets.bottom = 48;
766        state.insets.set(Some(insets));
767
768        // (848 - 48) / 2 = 400 high, above the bottom safe area of 24.
769        assert_eq!(manager.occluded_rect(), Rect::new(0.0, 1200.0 - 24.0 - 400.0, 540.0, 400.0));
770
771        state.insets.set(None);
772        assert_eq!(manager.occluded_rect(), Rect::default());
773    }
774
775    #[test]
776    fn applied_insets_report_the_safe_area_and_the_state_of_the_input_pane() {
777        let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
778        // Android 10: the state of the input pane is reported when it changes.
779        let (manager, state, _top_level) = manager(29, false);
780        manager.set_display_edge_to_edge_preference(true);
781        state.insets.set(Some(BARS));
782        let safe_areas = Rc::new(RefCell::new(Vec::new()));
783        let states = Rc::new(RefCell::new(Vec::new()));
784        let _ = manager.safe_area_changed(Rc::new({
785            let safe_areas = safe_areas.clone();
786            move |e: &SafeAreaChangedArgs| safe_areas.borrow_mut().push(e.safe_area_padding())
787        }));
788        let _ = manager.state_changed(Rc::new({
789            let states = states.clone();
790            move |e: &InputPaneStateEventArgs| states.borrow_mut().push((e.new_state(), e.end_rect()))
791        }));
792
793        manager.on_apply_window_insets(true, false, 0);
794        assert_eq!(*safe_areas.borrow(), [Thickness::new(0.0, 50.0, 0.0, 24.0)]);
795        assert_eq!(manager.state(), InputPaneState::Closed);
796        assert!(states.borrow().is_empty());
797
798        let mut insets = BARS;
799        insets.ime_bottom = 848;
800        insets.ime_visible = true;
801        state.insets.set(Some(insets));
802        manager.on_apply_window_insets(true, true, 848);
803
804        assert_eq!(manager.state(), InputPaneState::Open);
805        assert_eq!(*states.borrow(), [(InputPaneState::Open, Rect::new(0.0, 776.0, 540.0, 400.0))]);
806        assert_eq!(safe_areas.borrow().len(), 2);
807    }
808
809    #[test]
810    fn on_android_11_a_change_of_the_inset_of_the_input_method_is_reported() {
811        let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
812        let (manager, state, _top_level) = manager(30, false);
813        state.insets.set(Some(BARS));
814        let states = Rc::new(RefCell::new(Vec::new()));
815        let _ = manager.state_changed(Rc::new({
816            let states = states.clone();
817            move |e: &InputPaneStateEventArgs| states.borrow_mut().push(e.new_state())
818        }));
819
820        manager.on_apply_window_insets(true, false, 0);
821        assert!(states.borrow().is_empty());
822        manager.on_apply_window_insets(true, true, 700);
823        manager.on_apply_window_insets(true, true, 700);
824
825        assert_eq!(*states.borrow(), [InputPaneState::Open]);
826    }
827
828    #[test]
829    fn the_theme_of_the_bars_falls_back_to_the_theme_of_the_system() {
830        let (manager, state, _top_level) = manager(34, false);
831        state.calls.borrow_mut().clear();
832
833        manager.set_default_system_light_mode(true);
834        manager.set_system_bar_theme(Some(SystemBarTheme::Dark));
835        assert_eq!(manager.system_bar_theme(), Some(SystemBarTheme::Dark));
836        manager.set_system_bar_theme(None);
837        assert_eq!(manager.system_bar_theme(), Some(SystemBarTheme::Light));
838        assert_eq!(*state.calls.borrow(), ["light false", "light true"]);
839    }
840
841    #[test]
842    fn the_visibility_of_the_bars_is_asked_of_the_insets_and_set_on_the_window() {
843        let (manager, state, _top_level) = manager(34, false);
844        state.calls.borrow_mut().clear();
845
846        assert_eq!(manager.is_system_bar_visible(), None);
847        state.insets.set(Some(BARS));
848        assert_eq!(manager.is_system_bar_visible(), Some(true));
849
850        manager.set_is_system_bar_visible(Some(false));
851        manager.set_is_system_bar_visible(None);
852        assert_eq!(*state.calls.borrow(), ["visible false", "visible true"]);
853    }
854}
855
856#[cfg(test)]
857mod input_pane_tests {
858    // Not from the reference, which has no tests of the manager.
859    use super::*;
860    use std::cell::RefCell;
861
862    struct Window {
863        sdk_int: i32,
864        insets: Rc<Cell<Option<RootInsets>>>,
865    }
866
867    impl IInsetsWindow for Window {
868        fn sdk_int(&self) -> i32 {
869            self.sdk_int
870        }
871
872        fn has_window(&self) -> bool {
873            true
874        }
875
876        fn root_insets(&self) -> Option<RootInsets> {
877            self.insets.get()
878        }
879
880        fn is_display_edge_to_edge_forced(&self) -> bool {
881            true
882        }
883
884        fn set_layout_in_display_cutout_mode(&self, _short_edges: bool) {}
885        fn set_decor_fits_system_windows(&self, _decor_fits_system_windows: bool) {}
886        fn add_translucent_bars(&self) {}
887        fn set_system_bar_color(&self, _color: Color) {}
888        fn set_navigation_bar_contrast_enforced(&self, _enforced: bool) {}
889
890        fn appearance_light_status_bars(&self) -> bool {
891            true
892        }
893
894        fn set_appearance_light_bars(&self, _light: bool) {}
895        fn set_system_bars_visible(&self, _visible: bool) {}
896    }
897
898    struct TopLevel;
899
900    impl IInsetsTopLevel for TopLevel {
901        fn top_level_render_scaling(&self) -> f64 {
902            2.0
903        }
904
905        fn top_level_client_size(&self) -> Size {
906            Size::new(540.0, 1200.0)
907        }
908    }
909
910    type Seen = Rc<RefCell<Vec<(InputPaneState, Option<Rect>, Rect, Duration, bool)>>>;
911
912    fn manager(sdk_int: i32) -> (Rc<AndroidInsetsManager>, Rc<Cell<Option<RootInsets>>>, Seen, Rc<dyn IInsetsTopLevel>) {
913        let insets = Rc::new(Cell::new(Some(bars(0, false))));
914        let top_level: Rc<dyn IInsetsTopLevel> = Rc::new(TopLevel);
915        let manager =
916            AndroidInsetsManager::new(Box::new(Window { sdk_int, insets: insets.clone() }), Rc::downgrade(&top_level));
917        let seen: Seen = Rc::new(RefCell::new(Vec::new()));
918        std::mem::forget(manager.state_changed(Rc::new({
919            let seen = seen.clone();
920            move |e: &InputPaneStateEventArgs| {
921                seen.borrow_mut().push((
922                    e.new_state(),
923                    e.start_rect(),
924                    e.end_rect(),
925                    e.animation_duration(),
926                    e.easing().is_some(),
927                ));
928            }
929        })));
930        (manager, insets, seen, top_level)
931    }
932
933    /// Bars of 100 px at the top and 48 px at the bottom, and the input method.
934    fn bars(ime_bottom: i32, ime_visible: bool) -> RootInsets {
935        RootInsets {
936            left: 0,
937            top: 100,
938            right: 0,
939            bottom: 48,
940            navigation_bars_bottom: 48,
941            ime_bottom,
942            ime_visible,
943            system_bars_visible: true,
944        }
945    }
946
947    #[test]
948    fn from_api_30_the_animation_of_the_insets_reports_the_input_pane() {
949        let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
950        let (manager, insets, seen, _top_level) = manager(36);
951
952        // The insets with the input method are applied first: the state opens, silently.
953        insets.set(Some(bars(848, true)));
954        manager.on_apply_window_insets(true, true, 848);
955        assert_eq!(manager.state(), InputPaneState::Open);
956        assert!(seen.borrow().is_empty());
957
958        // Then the animation starts: from no input method to 848 px, in 285 ms.
959        manager.on_insets_animation_start(0, 848, 285, None);
960        // The client is 1200 high and its safe area 24 at the bottom; the pane is
961        // (848 - 48) / 2 = 400 high.
962        assert_eq!(
963            *seen.borrow(),
964            vec![(
965                InputPaneState::Open,
966                Some(Rect::new(0.0, 1176.0, 540.0, 0.0)),
967                Rect::new(0.0, 776.0, 540.0, 400.0),
968                Duration::from_millis(285),
969                false
970            )]
971        );
972        assert_eq!(manager.occluded_rect(), Rect::new(0.0, 776.0, 540.0, 400.0));
973
974        // Closing runs the other way.
975        seen.borrow_mut().clear();
976        insets.set(Some(bars(0, false)));
977        manager.on_apply_window_insets(true, false, 0);
978        manager.on_insets_animation_start(0, 848, 200, None);
979        assert_eq!(
980            *seen.borrow(),
981            vec![(
982                InputPaneState::Closed,
983                Some(Rect::new(0.0, 776.0, 540.0, 400.0)),
984                Rect::new(0.0, 1176.0, 540.0, 0.0),
985                Duration::from_millis(200),
986                false
987            )]
988        );
989    }
990
991    #[test]
992    fn below_api_30_the_global_layout_reports_the_input_pane_at_once() {
993        let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
994        let (manager, insets, seen, _top_level) = manager(29);
995
996        manager.on_global_layout();
997        assert!(seen.borrow().is_empty());
998
999        insets.set(Some(bars(848, true)));
1000        manager.on_global_layout();
1001        assert_eq!(manager.state(), InputPaneState::Open);
1002        assert_eq!(seen.borrow().len(), 1);
1003        let (state, _, end, duration, easing) = seen.borrow()[0];
1004        assert_eq!((state, end, duration, easing), (InputPaneState::Open, Rect::new(0.0, 776.0, 540.0, 400.0), Duration::ZERO, false));
1005
1006        // The same layout again changes nothing.
1007        manager.on_global_layout();
1008        assert_eq!(seen.borrow().len(), 1);
1009    }
1010
1011    #[test]
1012    fn from_api_30_the_global_layout_does_not_touch_the_state() {
1013        let _scope = ferroui_base::threading::Dispatcher::unit_test_scope();
1014        let (manager, insets, seen, _top_level) = manager(36);
1015        insets.set(Some(bars(848, true)));
1016        manager.on_global_layout();
1017        assert_eq!(manager.state(), InputPaneState::Closed);
1018        assert!(seen.borrow().is_empty());
1019    }
1020}