Skip to main content

ferroui_android/platform/
android_platform_settings.rs

1//! The settings of the platform: the theme, the accent colours and the
2//! contrast of the system, the sizes and times of taps, the language.
3//!
4//! The reference reads the values from the application context; here the
5//! context is behind [`ISettingsContext`], which the Java layer answers
6//! with the numbers (`PlatformHelper`), so that what is made of them is a
7//! function of values.
8
9use ferroui_base::input::platform::PlatformHotkeyConfiguration;
10use ferroui_base::input::PointerType;
11use ferroui_base::media::Color;
12use ferroui_base::platform::{
13    ColorContrastPreference, DefaultPlatformSettings, IPlatformSettings, PlatformColorValues, PlatformThemeVariant,
14};
15use ferroui_base::reactive::IDisposable;
16use ferroui_base::Size;
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19use std::time::Duration;
20
21/// The accent colours a context has, as ARGB numbers.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub(crate) enum AccentColors {
24    /// The three accent palettes of the system at tone 500 (API 31).
25    System(u32, u32, u32),
26    /// The accent colour of the theme of the context.
27    Theme(u32),
28    /// None could be read.
29    None,
30}
31
32/// What the reference reads of a context for the colour values.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub(crate) struct ColorInputs {
35    /// Whether the night mode of the configuration is "yes".
36    pub night: bool,
37    /// Whether the high contrast text of the system is on.
38    pub high_contrast: bool,
39    pub accents: AccentColors,
40}
41
42/// What the reference reads of the view configuration.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub(crate) struct InputConfig {
45    /// `ViewConfiguration.getLongPressTimeout`, in milliseconds.
46    pub long_press_timeout: i32,
47    /// `ViewConfiguration.getMultiPressTimeout` (API 31), in milliseconds.
48    pub multi_press_timeout: Option<i32>,
49    /// The scaled double tap slop and the scaled touch slop of the view
50    /// configuration of the context, in pixels; `None` without one.
51    pub slops: Option<(i32, i32)>,
52    /// The density of the display metrics of the context.
53    pub density: f32,
54}
55
56/// The application context, as far as the settings read it.
57pub(crate) trait ISettingsContext {
58    fn color_inputs(&self) -> ColorInputs;
59    fn input_config(&self) -> InputConfig;
60    /// The language tag of the first locale of the configuration.
61    fn preferred_application_language(&self) -> Option<String>;
62    /// Registers the receiver of the configuration changes
63    /// (`Intent.ACTION_CONFIGURATION_CHANGED`), which calls
64    /// [`AndroidPlatformSettings::on_receive`].
65    fn register_configuration_changed_receiver(&self);
66}
67
68ferroui_base::ferro_thread_local! {
69    static INSTANCE: RefCell<Option<Rc<AndroidPlatformSettings>>> = const { RefCell::new(None) };
70}
71
72pub struct AndroidPlatformSettings {
73    base: DefaultPlatformSettings,
74    context: Option<Box<dyn ISettingsContext>>,
75    color_values: Cell<PlatformColorValues>,
76    hold_wait_duration: Cell<Duration>,
77    double_tap_time: Cell<Duration>,
78    double_tap_size: Cell<Size>,
79    tap_size: Cell<Size>,
80    latest_language: RefCell<Option<String>>,
81}
82
83impl AndroidPlatformSettings {
84    /// The settings read from `context`; `None` is a process without an
85    /// application context, which has the default values.
86    pub(crate) fn new(context: Option<Box<dyn ISettingsContext>>) -> Rc<AndroidPlatformSettings> {
87        let base = DefaultPlatformSettings::new();
88        let this = AndroidPlatformSettings {
89            color_values: Cell::new(base.get_color_values()),
90            base,
91            context,
92            hold_wait_duration: Cell::new(Duration::from_millis(300)),
93            double_tap_time: Cell::new(Duration::from_millis(500)),
94            double_tap_size: Cell::new(Size::new(16.0, 16.0)),
95            tap_size: Cell::new(Size::new(10.0, 10.0)),
96            latest_language: RefCell::new(None),
97        };
98
99        if let Some(context) = &this.context {
100            this.update_input_config_values(context.as_ref());
101            this.color_values.set(Self::get_color_values_from_context(context.as_ref()));
102            *this.latest_language.borrow_mut() = Self::query_preferred_application_language(Some(context.as_ref()));
103
104            context.register_configuration_changed_receiver();
105        }
106
107        let this = Rc::new(this);
108        // The receiver of the Java layer finds the settings here.
109        INSTANCE.with(|instance| *instance.borrow_mut() = Some(this.clone()));
110        this
111    }
112
113    fn update_color_values(&self, context: &dyn ISettingsContext) {
114        let old_color_values = self.color_values.get();
115        let color_values = Self::get_color_values_from_context(context);
116
117        if old_color_values != color_values {
118            self.color_values.set(color_values);
119            self.base.on_color_values_changed(color_values);
120        }
121    }
122
123    fn update_preferred_application_language(&self, context: &dyn ISettingsContext) {
124        let old_language = self.latest_language.borrow().clone();
125        let language = Self::query_preferred_application_language(Some(context));
126
127        if old_language != language {
128            *self.latest_language.borrow_mut() = language;
129            self.base.on_preferred_application_language_changed();
130        }
131    }
132
133    pub(crate) fn get_color_values_from_context(context: &dyn ISettingsContext) -> PlatformColorValues {
134        let inputs = context.color_inputs();
135        let system_theme = if inputs.night { PlatformThemeVariant::Dark } else { PlatformThemeVariant::Light };
136        let contrast_preference =
137            if inputs.high_contrast { ColorContrastPreference::High } else { ColorContrastPreference::NoPreference };
138
139        let values =
140            PlatformColorValues::new().with_theme_variant(system_theme).with_contrast_preference(contrast_preference);
141        match inputs.accents {
142            AccentColors::System(accent1, accent2, accent3) => values
143                .with_accent_color1(Color::from_uint32(accent1))
144                .with_accent_color2(Color::from_uint32(accent2))
145                .with_accent_color3(Color::from_uint32(accent3)),
146            AccentColors::Theme(accent) => values.with_accent_color1(Color::from_uint32(accent)),
147            AccentColors::None => values,
148        }
149    }
150
151    fn update_input_config_values(&self, context: &dyn ISettingsContext) {
152        let config = context.input_config();
153        self.hold_wait_duration.set(Duration::from_millis(u64::try_from(config.long_press_timeout).unwrap_or(0)));
154
155        if let Some(multi_press_timeout) = config.multi_press_timeout {
156            self.double_tap_time.set(Duration::from_millis(u64::try_from(multi_press_timeout).unwrap_or(0)));
157        }
158        let scaling = config.density;
159        if let Some((scaled_double_tap_slop, scaled_touch_slop)) = config.slops {
160            // The arithmetic of the reference: an integer doubled, divided by the density
161            // as a single precision number.
162            let size = f64::from((scaled_double_tap_slop * 2) as f32 / scaling);
163            self.double_tap_size.set(Size::new(size, size));
164            let size = f64::from((scaled_touch_slop * 2) as f32 / scaling);
165            self.tap_size.set(Size::new(size, size));
166        }
167    }
168
169    fn query_preferred_application_language(context: Option<&dyn ISettingsContext>) -> Option<String> {
170        context?.preferred_application_language().filter(|tag| !tag.is_empty())
171    }
172
173    /// The receiver of the configuration changes was called.
174    pub(crate) fn on_receive() {
175        let Some(settings) = INSTANCE.with(|instance| instance.borrow().clone()) else {
176            return;
177        };
178        settings.schedule_update();
179    }
180
181    #[cfg(target_os = "android")]
182    fn schedule_update(self: &Rc<Self>) {
183        use ferroui_base::threading::{DispatcherPriority, DispatcherTimer};
184
185        // The context might still have the old values at this point because they haven't been processed yet.
186        // Postpone the update 100ms arbitrarily. Not an ideal solution, but sufficient.
187        let settings = self.clone();
188        DispatcherTimer::run_once(move || settings.update(), Duration::from_millis(100), DispatcherPriority::NORMAL);
189    }
190
191    #[cfg(not(target_os = "android"))]
192    fn schedule_update(self: &Rc<Self>) {
193        // No dispatcher of the platform on another system: the tests call `update`.
194        self.update();
195    }
196
197    /// Reads the context again and raises what changed.
198    pub(crate) fn update(&self) {
199        if let Some(context) = &self.context {
200            self.update_input_config_values(context.as_ref());
201            self.update_color_values(context.as_ref());
202            self.update_preferred_application_language(context.as_ref());
203        }
204    }
205}
206
207impl IPlatformSettings for AndroidPlatformSettings {
208    fn get_tap_size(&self, type_: PointerType) -> Size {
209        if type_ == PointerType::Mouse {
210            self.base.get_tap_size(type_)
211        } else {
212            self.tap_size.get()
213        }
214    }
215
216    fn get_double_tap_size(&self, type_: PointerType) -> Size {
217        if type_ == PointerType::Mouse {
218            self.base.get_double_tap_size(type_)
219        } else {
220            self.double_tap_size.get()
221        }
222    }
223
224    fn get_double_tap_time(&self, type_: PointerType) -> Duration {
225        if type_ == PointerType::Mouse {
226            self.base.get_double_tap_time(type_)
227        } else {
228            self.double_tap_time.get()
229        }
230    }
231
232    fn hold_wait_duration(&self) -> Duration {
233        self.hold_wait_duration.get()
234    }
235
236    fn hotkey_configuration(&self) -> Rc<PlatformHotkeyConfiguration> {
237        self.base.hotkey_configuration()
238    }
239
240    fn preferred_application_language(&self) -> String {
241        match &*self.latest_language.borrow() {
242            Some(language) => language.clone(),
243            None => self.base.preferred_application_language(),
244        }
245    }
246
247    fn get_color_values(&self) -> PlatformColorValues {
248        self.color_values.get()
249    }
250
251    fn color_values_changed(&self, handler: Rc<dyn Fn(&PlatformColorValues)>) -> Rc<dyn IDisposable> {
252        self.base.color_values_changed(handler)
253    }
254
255    fn preferred_application_language_changed(&self, handler: Rc<dyn Fn()>) -> Rc<dyn IDisposable> {
256        self.base.preferred_application_language_changed(handler)
257    }
258}
259
260/// The application context of the system.
261#[cfg(target_os = "android")]
262pub(crate) struct ApplicationSettingsContext {
263    context: crate::interop::java::JavaObject,
264}
265
266#[cfg(target_os = "android")]
267impl ApplicationSettingsContext {
268    pub fn new(context: crate::interop::java::JavaObject) -> Self {
269        Self { context }
270    }
271
272    fn helper() -> crate::interop::java::JavaClass {
273        crate::interop::java::JavaClass::find(crate::interop::natives::PLATFORM_HELPER)
274    }
275}
276
277#[cfg(target_os = "android")]
278impl ISettingsContext for ApplicationSettingsContext {
279    fn color_inputs(&self) -> ColorInputs {
280        use crate::interop::java::{call_static_object, int_array_of, JavaValue};
281
282        let values = call_static_object(
283            &Self::helper(),
284            "getColorValues",
285            "(Landroid/content/Context;)[I",
286            &[JavaValue::Object(Some(&self.context))],
287        )
288        .map(|values| int_array_of(&values))
289        .unwrap_or_default();
290        color_inputs_from_values(&values)
291    }
292
293    fn input_config(&self) -> InputConfig {
294        use crate::interop::java::{call_static_object, float_array_of, JavaValue};
295
296        let values = call_static_object(
297            &Self::helper(),
298            "getInputConfigValues",
299            "(Landroid/content/Context;)[F",
300            &[JavaValue::Object(Some(&self.context))],
301        )
302        .map(|values| float_array_of(&values))
303        .unwrap_or_default();
304        input_config_from_values(&values)
305    }
306
307    fn preferred_application_language(&self) -> Option<String> {
308        use crate::interop::java::{call_static_object, string_of, JavaValue};
309
310        call_static_object(
311            &Self::helper(),
312            "getPreferredApplicationLanguage",
313            "(Landroid/content/Context;)Ljava/lang/String;",
314            &[JavaValue::Object(Some(&self.context))],
315        )
316        .map(|tag| string_of(&tag))
317    }
318
319    fn register_configuration_changed_receiver(&self) {
320        use crate::interop::java::{call_static_void, JavaClass, JavaValue};
321
322        call_static_void(
323            &JavaClass::find(crate::interop::natives::CONFIGURATION_CHANGED_RECEIVER),
324            "register",
325            "(Landroid/content/Context;)V",
326            &[JavaValue::Object(Some(&self.context))],
327        );
328    }
329}
330
331/// Reads what `PlatformHelper.getColorValues` answers: whether the night
332/// mode is on, whether the high contrast text is on, the number of accent
333/// colours (three of the system, one of the theme, or none) and the
334/// colours.
335pub(crate) fn color_inputs_from_values(values: &[i32]) -> ColorInputs {
336    let at = |index: usize| values.get(index).copied().unwrap_or(0);
337    let color = |index: usize| at(index) as u32;
338    ColorInputs {
339        night: at(0) != 0,
340        high_contrast: at(1) != 0,
341        accents: match at(2) {
342            3 => AccentColors::System(color(3), color(4), color(5)),
343            1 => AccentColors::Theme(color(3)),
344            _ => AccentColors::None,
345        },
346    }
347}
348
349/// Reads what `PlatformHelper.getInputConfigValues` answers: the long press
350/// timeout, the multi press timeout or a negative number, whether the
351/// context has a view configuration, its scaled double tap slop and scaled
352/// touch slop, and the density of the display metrics.
353pub(crate) fn input_config_from_values(values: &[f32]) -> InputConfig {
354    let at = |index: usize| values.get(index).copied().unwrap_or(0.0);
355    InputConfig {
356        long_press_timeout: at(0) as i32,
357        multi_press_timeout: (at(1) >= 0.0).then_some(at(1) as i32),
358        slops: (at(2) != 0.0).then_some((at(3) as i32, at(4) as i32)),
359        density: if values.len() > 5 { at(5) } else { 1.0 },
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    // Not from the reference, which has no tests of the settings.
366    use super::*;
367
368    struct TestContext {
369        colors: Rc<Cell<ColorInputs>>,
370        input: Rc<Cell<InputConfig>>,
371        language: Rc<RefCell<Option<String>>>,
372        registered: Rc<Cell<u32>>,
373    }
374
375    struct Fixture {
376        colors: Rc<Cell<ColorInputs>>,
377        input: Rc<Cell<InputConfig>>,
378        language: Rc<RefCell<Option<String>>>,
379        registered: Rc<Cell<u32>>,
380        settings: Rc<AndroidPlatformSettings>,
381    }
382
383    impl ISettingsContext for TestContext {
384        fn color_inputs(&self) -> ColorInputs {
385            self.colors.get()
386        }
387
388        fn input_config(&self) -> InputConfig {
389            self.input.get()
390        }
391
392        fn preferred_application_language(&self) -> Option<String> {
393            self.language.borrow().clone()
394        }
395
396        fn register_configuration_changed_receiver(&self) {
397            self.registered.set(self.registered.get() + 1);
398        }
399    }
400
401    fn fixture() -> Fixture {
402        let colors = Rc::new(Cell::new(ColorInputs {
403            night: false,
404            high_contrast: false,
405            accents: AccentColors::System(0xff11_2233, 0xff44_5566, 0xff77_8899),
406        }));
407        let input = Rc::new(Cell::new(InputConfig {
408            long_press_timeout: 400,
409            multi_press_timeout: Some(300),
410            slops: Some((262, 21)),
411            density: 2.625,
412        }));
413        let language = Rc::new(RefCell::new(Some("pl-PL".to_string())));
414        let registered = Rc::new(Cell::new(0));
415        let settings = AndroidPlatformSettings::new(Some(Box::new(TestContext {
416            colors: colors.clone(),
417            input: input.clone(),
418            language: language.clone(),
419            registered: registered.clone(),
420        })));
421        Fixture { colors, input, language, registered, settings }
422    }
423
424    #[test]
425    fn without_a_context_the_settings_are_the_defaults() {
426        let settings = AndroidPlatformSettings::new(None);
427        let base = DefaultPlatformSettings::new();
428        assert_eq!(settings.get_color_values(), base.get_color_values());
429        assert_eq!(settings.hold_wait_duration(), Duration::from_millis(300));
430        assert_eq!(settings.get_double_tap_time(PointerType::Touch), Duration::from_millis(500));
431        assert_eq!(settings.get_double_tap_size(PointerType::Touch), Size::new(16.0, 16.0));
432        assert_eq!(settings.get_tap_size(PointerType::Pen), Size::new(10.0, 10.0));
433        assert_eq!(settings.preferred_application_language(), base.preferred_application_language());
434    }
435
436    #[test]
437    fn the_values_of_the_context_are_read_and_the_receiver_is_registered() {
438        let f = fixture();
439        assert_eq!(f.registered.get(), 1);
440
441        let colors = f.settings.get_color_values();
442        assert_eq!(colors.theme_variant(), PlatformThemeVariant::Light);
443        assert_eq!(colors.contrast_preference(), ColorContrastPreference::NoPreference);
444        assert_eq!(colors.accent_color1(), Color::from_argb(0xff, 0x11, 0x22, 0x33));
445        assert_eq!(colors.accent_color2(), Color::from_argb(0xff, 0x44, 0x55, 0x66));
446        assert_eq!(colors.accent_color3(), Color::from_argb(0xff, 0x77, 0x88, 0x99));
447
448        assert_eq!(f.settings.hold_wait_duration(), Duration::from_millis(400));
449        assert_eq!(f.settings.get_double_tap_time(PointerType::Touch), Duration::from_millis(300));
450        let double_tap = f64::from(524.0f32 / 2.625f32);
451        assert_eq!(f.settings.get_double_tap_size(PointerType::Touch), Size::new(double_tap, double_tap));
452        let tap = f64::from(42.0f32 / 2.625f32);
453        assert_eq!(f.settings.get_tap_size(PointerType::Touch), Size::new(tap, tap));
454        assert_eq!(f.settings.preferred_application_language(), "pl-PL");
455    }
456
457    #[test]
458    fn a_mouse_has_the_values_of_the_framework() {
459        let f = fixture();
460        let base = DefaultPlatformSettings::new();
461        assert_eq!(f.settings.get_tap_size(PointerType::Mouse), base.get_tap_size(PointerType::Mouse));
462        assert_eq!(f.settings.get_double_tap_size(PointerType::Mouse), base.get_double_tap_size(PointerType::Mouse));
463        assert_eq!(f.settings.get_double_tap_time(PointerType::Mouse), base.get_double_tap_time(PointerType::Mouse));
464    }
465
466    #[test]
467    fn a_configuration_change_raises_what_changed_and_only_that() {
468        let f = fixture();
469        let color_changes = Rc::new(RefCell::new(Vec::new()));
470        let language_changes = Rc::new(Cell::new(0));
471        let _colors = f.settings.color_values_changed(Rc::new({
472            let color_changes = color_changes.clone();
473            move |values: &PlatformColorValues| color_changes.borrow_mut().push(*values)
474        }));
475        let _language = f.settings.preferred_application_language_changed(Rc::new({
476            let language_changes = language_changes.clone();
477            move || language_changes.set(language_changes.get() + 1)
478        }));
479
480        // Nothing changed.
481        AndroidPlatformSettings::on_receive();
482        assert!(color_changes.borrow().is_empty());
483        assert_eq!(language_changes.get(), 0);
484
485        // Night mode and high contrast.
486        f.colors.set(ColorInputs { night: true, high_contrast: true, accents: AccentColors::Theme(0xff00_ff00) });
487        AndroidPlatformSettings::on_receive();
488        assert_eq!(color_changes.borrow().len(), 1);
489        let colors = f.settings.get_color_values();
490        assert_eq!(colors, color_changes.borrow()[0]);
491        assert_eq!(colors.theme_variant(), PlatformThemeVariant::Dark);
492        assert_eq!(colors.contrast_preference(), ColorContrastPreference::High);
493        assert_eq!(colors.accent_color1(), Color::from_argb(0xff, 0, 0xff, 0));
494        // Without a second and a third accent they follow the first.
495        assert_eq!(colors.accent_color2(), colors.accent_color1());
496        assert_eq!(language_changes.get(), 0);
497
498        // The language, and the density (a display change).
499        *f.language.borrow_mut() = Some("de-DE".to_string());
500        f.input.set(InputConfig { long_press_timeout: 500, multi_press_timeout: None, slops: None, density: 1.0 });
501        AndroidPlatformSettings::on_receive();
502        assert_eq!(language_changes.get(), 1);
503        assert_eq!(f.settings.preferred_application_language(), "de-DE");
504        assert_eq!(f.settings.hold_wait_duration(), Duration::from_millis(500));
505        // No multi press timeout below API 31 and no view configuration: the values stay.
506        assert_eq!(f.settings.get_double_tap_time(PointerType::Touch), Duration::from_millis(300));
507        assert_eq!(color_changes.borrow().len(), 1);
508
509        // An empty language tag is no language: the language of the framework.
510        *f.language.borrow_mut() = Some(String::new());
511        AndroidPlatformSettings::on_receive();
512        assert_eq!(language_changes.get(), 2);
513        assert_eq!(
514            f.settings.preferred_application_language(),
515            DefaultPlatformSettings::new().preferred_application_language()
516        );
517    }
518
519    #[test]
520    fn the_answers_of_the_java_layer_are_read() {
521        assert_eq!(
522            color_inputs_from_values(&[1, 0, 3, 0xff11_2233u32 as i32, 0xff44_5566u32 as i32, 0xff77_8899u32 as i32]),
523            ColorInputs {
524                night: true,
525                high_contrast: false,
526                accents: AccentColors::System(0xff11_2233, 0xff44_5566, 0xff77_8899),
527            }
528        );
529        assert_eq!(
530            color_inputs_from_values(&[0, 1, 1, 0xff00_ff00u32 as i32, 0, 0]),
531            ColorInputs { night: false, high_contrast: true, accents: AccentColors::Theme(0xff00_ff00) }
532        );
533        assert_eq!(
534            color_inputs_from_values(&[]),
535            ColorInputs { night: false, high_contrast: false, accents: AccentColors::None }
536        );
537
538        assert_eq!(
539            input_config_from_values(&[400.0, 300.0, 1.0, 262.0, 21.0, 2.625]),
540            InputConfig { long_press_timeout: 400, multi_press_timeout: Some(300), slops: Some((262, 21)), density: 2.625 }
541        );
542        assert_eq!(
543            input_config_from_values(&[400.0, -1.0, 0.0, 0.0, 0.0, 1.5]),
544            InputConfig { long_press_timeout: 400, multi_press_timeout: None, slops: None, density: 1.5 }
545        );
546    }
547}