Skip to main content

ferroui_themes_material/
android_colors.rs

1// Copyright 2026 The Android Open Source Project (system color resource mappings).
2// Copyright 2026 FerroUI contributors (native resource bridge and lifecycle).
3// SPDX-License-Identifier: Apache-2.0
4//! Native Android wallpaper/system colors, without application JNI or Java glue.
5//!
6//! Resource mappings and neutral-tone calculations follow AndroidX Material3
7//! `e4d933c98df7baa7ea2b83eea6e086915961e0ff`, `DynamicTonalPalette.android.kt`
8//! and `src/androidMain/res/values-v{31,34}/styles.xml`.
9
10use crate::{MaterialDynamicColorError, MaterialTheme};
11use std::fmt;
12
13/// Which native Android resource contract supplied a dynamic color scheme.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum MaterialAndroidColorSource {
16    /// Android 12/13: the five wallpaper tonal palettes, with AndroidX neutral tones.
17    Android31TonalPalette,
18    /// Android 14 and later: complete system roles, including the device's contrast adjustments.
19    Android34ColorRoles,
20}
21
22/// A native import failure. Existing theme colors remain installed when an import fails.
23#[derive(Clone, Debug, PartialEq)]
24pub enum MaterialAndroidColorError {
25    UnsupportedPlatform,
26    FeatureDisabled,
27    ApplicationNotInitialized,
28    ResourcesUnavailable,
29    DynamicColorsUnavailable,
30    IncompletePalette(MaterialDynamicColorError),
31}
32
33impl fmt::Display for MaterialAndroidColorError {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::UnsupportedPlatform => {
37                f.write_str("Android system colors are available on Android only")
38            }
39            Self::FeatureDisabled => f.write_str(
40                "enable the android-dynamic-color feature to import Android system colors",
41            ),
42            Self::ApplicationNotInitialized => {
43                f.write_str("the FerroUI Android application has not been initialized")
44            }
45            Self::ResourcesUnavailable => {
46                f.write_str("the Android application has no Resources object")
47            }
48            Self::DynamicColorsUnavailable => {
49                f.write_str("Android dynamic color resources require Android 12/API 31 or later")
50            }
51            Self::IncompletePalette(error) => error.fmt(f),
52        }
53    }
54}
55
56impl std::error::Error for MaterialAndroidColorError {
57    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
58        match self {
59            Self::IncompletePalette(error) => Some(error),
60            _ => None,
61        }
62    }
63}
64
65impl From<MaterialDynamicColorError> for MaterialAndroidColorError {
66    fn from(value: MaterialDynamicColorError) -> Self {
67        Self::IncompletePalette(value)
68    }
69}
70
71impl MaterialTheme {
72    /// Imports the device's light and dark wallpaper color schemes and refreshes them when
73    /// FerroUI's platform settings report changed colors or theme configuration.
74    ///
75    /// Enable the `android-dynamic-color` Cargo feature and call this after the Android
76    /// application is initialized. The bridge obtains the existing application context itself;
77    /// it requires no Java code, AndroidX library, resource packaging, or JNI setup in the app.
78    /// Repeating this call replaces the previous subscription. The subscription is disposed
79    /// when the theme is dropped or [`Self::stop_android_dynamic_colors`] is called.
80    ///
81    /// On API 34+ all system roles, including fixed colors and current system contrast, are
82    /// imported directly. API 31–33 uses AndroidX's five tonal palettes and float CAM16/HCT
83    /// neutral-tone derivation. Older systems return an error and retain the existing palette.
84    /// Desktop builds do not link the Android backend and return `UnsupportedPlatform`.
85    ///
86    /// Call [`Self::refresh_android_dynamic_colors`] on a custom host notification if that host
87    /// changes Android resource overlays without a FerroUI platform color-change notification.
88    pub fn use_android_dynamic_colors(
89        &self,
90    ) -> Result<MaterialAndroidColorSource, MaterialAndroidColorError> {
91        let source = self.refresh_android_dynamic_colors()?;
92        #[cfg(all(target_os = "android", feature = "android-dynamic-color"))]
93        native::subscribe(self);
94        Ok(source)
95    }
96
97    /// Re-reads the current native palette. Both variants are validated before either is
98    /// installed. This is also useful after custom Android resource-overlay changes.
99    pub fn refresh_android_dynamic_colors(
100        &self,
101    ) -> Result<MaterialAndroidColorSource, MaterialAndroidColorError> {
102        #[cfg(all(target_os = "android", feature = "android-dynamic-color"))]
103        {
104            use ferroui_base::styling::ThemeVariant;
105            let schemes = native::read()?;
106            self.set_color_scheme(ThemeVariant::light(), &schemes.light);
107            self.set_color_scheme(ThemeVariant::dark(), &schemes.dark);
108            self.set_color_scheme(ThemeVariant::default(), &schemes.dark);
109            Ok(schemes.source)
110        }
111        #[cfg(all(target_os = "android", not(feature = "android-dynamic-color")))]
112        {
113            Err(MaterialAndroidColorError::FeatureDisabled)
114        }
115        #[cfg(not(target_os = "android"))]
116        {
117            Err(MaterialAndroidColorError::UnsupportedPlatform)
118        }
119    }
120
121    /// Stops following platform changes while retaining the most recently imported colors.
122    pub fn stop_android_dynamic_colors(&self) {
123        let old = self.android_color_subscription.borrow_mut().take();
124        if let Some(subscription) = old {
125            subscription.dispose();
126        }
127    }
128}
129
130#[cfg(any(test, all(target_os = "android", feature = "android-dynamic-color")))]
131#[path = "android_palette_math.rs"]
132mod palette_math;
133
134#[cfg(any(test, all(target_os = "android", feature = "android-dynamic-color")))]
135mod mapping {
136    use super::{palette_math, MaterialAndroidColorError, MaterialAndroidColorSource};
137    use crate::{
138        MaterialColorRole, MaterialColorScheme, MaterialDynamicPalette, MaterialPaletteFamily,
139        MaterialTonalPalette,
140    };
141    use ferroui_base::media::Color;
142
143    pub(super) struct Schemes {
144        pub light: MaterialColorScheme,
145        pub dark: MaterialColorScheme,
146        pub source: MaterialAndroidColorSource,
147    }
148
149    pub(super) fn read(
150        mut resolve: impl FnMut(&str) -> Option<Color>,
151    ) -> Result<Schemes, MaterialAndroidColorError> {
152        if resolve("system_primary_light").is_some() {
153            let mut import = |dark| {
154                MaterialColorScheme::from_android_system_roles(dark, |role| {
155                    role_resource(role, dark).and_then(|name| resolve(&name))
156                })
157            };
158            return Ok(Schemes {
159                light: import(false)?,
160                dark: import(true)?,
161                source: MaterialAndroidColorSource::Android34ColorRoles,
162            });
163        }
164        if resolve("system_accent1_0").is_none() {
165            return Err(MaterialAndroidColorError::DynamicColorsUnavailable);
166        }
167
168        // The eleven additional neutral tones all derive from the *same* tone40 color.
169        // Keep the platform's thirteen actual tones unchanged, including their alpha.
170        let mut neutral40 = [None; 2];
171        let palette = MaterialDynamicPalette::from_android31_colors(|family, tone| {
172            let name = palette_resource(family, tone);
173            if MaterialTonalPalette::ANDROID_SYSTEM_TONES.contains(&tone) {
174                return resolve(&name);
175            }
176            let slot = match family {
177                MaterialPaletteFamily::Neutral => 0,
178                MaterialPaletteFamily::NeutralVariant => 1,
179                _ => return None,
180            };
181            let base = match neutral40[slot] {
182                Some(color) => color,
183                None => {
184                    let color = resolve(&palette_resource(family, 40))?;
185                    neutral40[slot] = Some(color);
186                    color
187                }
188            };
189            Some(palette_math::set_luminance(base, tone))
190        })?;
191        Ok(Schemes {
192            light: palette.scheme_android31(false)?,
193            dark: palette.scheme_android31(true)?,
194            source: MaterialAndroidColorSource::Android31TonalPalette,
195        })
196    }
197
198    /// API34 public system color names, equivalent to AndroidX's mc3 scheme style mappings.
199    pub(super) fn role_resource(role: MaterialColorRole, dark: bool) -> Option<String> {
200        use MaterialColorRole as R;
201        let (role, dark) = match role {
202            R::Error | R::OnError | R::ErrorContainer | R::OnErrorContainer | R::Scrim => {
203                return None
204            }
205            R::InversePrimary => (R::Primary, !dark),
206            R::InverseSurface => (R::Surface, !dark),
207            R::InverseOnSurface => (R::OnSurface, !dark),
208            R::SurfaceTint => (R::Primary, dark),
209            _ => (role, dark),
210        };
211        let mut result = String::from("system_");
212        for (index, ch) in role.name().chars().enumerate() {
213            if index != 0 && ch.is_ascii_uppercase() {
214                result.push('_');
215            }
216            result.push(ch.to_ascii_lowercase());
217        }
218        if !role.name().contains("Fixed") {
219            result.push_str(if dark { "_dark" } else { "_light" });
220        }
221        Some(result)
222    }
223
224    pub(super) fn palette_resource(family: MaterialPaletteFamily, tone: u8) -> String {
225        let family = match family {
226            MaterialPaletteFamily::Primary => "accent1",
227            MaterialPaletteFamily::Secondary => "accent2",
228            MaterialPaletteFamily::Tertiary => "accent3",
229            MaterialPaletteFamily::Neutral => "neutral1",
230            MaterialPaletteFamily::NeutralVariant => "neutral2",
231        };
232        format!("system_{family}_{}", (100 - u16::from(tone)) * 10)
233    }
234}
235
236#[cfg(all(target_os = "android", feature = "android-dynamic-color"))]
237mod native {
238    use super::{mapping, MaterialAndroidColorError, MaterialTheme};
239    use ferroui_android::interop::java::{call_int, call_object, JavaRef, JavaValue};
240    use ferroui_android::FerroAndroidApplication;
241    use ferroui_base::media::Color;
242    use ferroui_base::platform::IPlatformSettings;
243    use ferroui_base::reactive::IDisposable;
244    use ferroui_base::{FerroLocator, LocatorExtensions};
245    use std::cell::Cell;
246    use std::rc::Rc;
247
248    pub(super) fn read() -> Result<mapping::Schemes, MaterialAndroidColorError> {
249        let context = FerroAndroidApplication::try_context()
250            .ok_or(MaterialAndroidColorError::ApplicationNotInitialized)?;
251        let resources = call_object(
252            &context,
253            "getResources",
254            "()Landroid/content/res/Resources;",
255            &[],
256        )
257        .ok_or(MaterialAndroidColorError::ResourcesUnavailable)?;
258        mapping::read(|name| color(&resources, name))
259    }
260
261    fn color(resources: &dyn JavaRef, name: &str) -> Option<Color> {
262        let identifier = call_int(
263            resources,
264            "getIdentifier",
265            "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
266            &[
267                JavaValue::String(name),
268                JavaValue::String("color"),
269                JavaValue::String("android"),
270            ],
271        );
272        if identifier == 0 {
273            return None;
274        }
275        Some(Color::from_uint32(call_int(
276            resources,
277            "getColor",
278            "(ILandroid/content/res/Resources$Theme;)I",
279            &[JavaValue::Int(identifier), JavaValue::Object(None)],
280        ) as u32))
281    }
282
283    pub(super) fn subscribe(theme: &MaterialTheme) {
284        theme.stop_android_dynamic_colors();
285        let Some(settings) = FerroLocator::current().get_service::<dyn IPlatformSettings>() else {
286            return;
287        };
288        let weak = theme.to_ref().downgrade();
289        let active = Rc::new(Cell::new(true));
290        let handler_active = active.clone();
291        let subscription = settings.color_values_changed(Rc::new(move |_| {
292            if handler_active.get() {
293                if let Some(theme) = weak.upgrade() {
294                    // Validation occurs before installation: retain the last complete palette
295                    // if the host is temporarily between resource configurations.
296                    let _ = theme.refresh_android_dynamic_colors();
297                }
298            }
299        }));
300        *theme.android_color_subscription.borrow_mut() = Some(Rc::new(Subscription {
301            subscription,
302            active,
303        }));
304    }
305
306    struct Subscription {
307        subscription: Rc<dyn IDisposable>,
308        active: Rc<Cell<bool>>,
309    }
310
311    impl IDisposable for Subscription {
312        fn dispose(&self) {
313            if self.active.replace(false) {
314                self.subscription.dispose();
315            }
316        }
317    }
318
319    impl Drop for Subscription {
320        fn drop(&mut self) {
321            self.dispose();
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::{MaterialColorRole as R, MaterialColorScheme, MaterialPaletteFamily as F};
330    use ferroui_base::media::Color;
331
332    #[test]
333    fn native_resource_names_match_androidx_inverse_fixed_and_surface_mappings() {
334        assert_eq!(
335            mapping::role_resource(R::Primary, false).as_deref(),
336            Some("system_primary_light")
337        );
338        assert_eq!(
339            mapping::role_resource(R::InversePrimary, false).as_deref(),
340            Some("system_primary_dark")
341        );
342        assert_eq!(
343            mapping::role_resource(R::InverseOnSurface, true).as_deref(),
344            Some("system_on_surface_light")
345        );
346        assert_eq!(
347            mapping::role_resource(R::SurfaceTint, true).as_deref(),
348            Some("system_primary_dark")
349        );
350        assert_eq!(
351            mapping::role_resource(R::SurfaceContainerHighest, false).as_deref(),
352            Some("system_surface_container_highest_light")
353        );
354        assert_eq!(
355            mapping::role_resource(R::OnTertiaryFixedVariant, true).as_deref(),
356            Some("system_on_tertiary_fixed_variant")
357        );
358        assert_eq!(mapping::role_resource(R::Error, false), None);
359        assert_eq!(
360            mapping::palette_resource(F::Primary, 99),
361            "system_accent1_10"
362        );
363        assert_eq!(
364            mapping::palette_resource(F::NeutralVariant, 40),
365            "system_neutral2_600"
366        );
367        assert_eq!(
368            mapping::palette_resource(F::Tertiary, 95),
369            "system_accent3_50"
370        );
371    }
372
373    #[test]
374    fn android34_import_is_lossless_and_keeps_androidx_error_defaults() {
375        let device = Color::from_argb(231, 51, 102, 153);
376        let schemes = mapping::read(|_| Some(device)).unwrap();
377        assert_eq!(
378            schemes.source,
379            MaterialAndroidColorSource::Android34ColorRoles
380        );
381        for scheme in [&schemes.light, &schemes.dark] {
382            assert_eq!(scheme.color(R::Primary), device);
383            assert_eq!(scheme.color(R::SurfaceContainerHighest), device);
384            assert_eq!(scheme.color(R::OnTertiaryFixedVariant), device);
385        }
386        assert_eq!(
387            schemes.light.color(R::Error),
388            MaterialColorScheme::light().color(R::Error)
389        );
390        assert_eq!(
391            schemes.dark.color(R::Scrim),
392            MaterialColorScheme::dark().color(R::Scrim)
393        );
394    }
395
396    #[test]
397    fn incomplete_android34_resources_are_not_replaced_by_the_older_algorithm() {
398        let result = mapping::read(|name| {
399            if name == "system_surface_container_high_dark" {
400                None
401            } else {
402                Some(Color::from_rgb(10, 20, 30))
403            }
404        });
405        assert!(matches!(
406            result,
407            Err(MaterialAndroidColorError::IncompletePalette(
408                MaterialDynamicColorError::MissingSystemRole(R::SurfaceContainerHigh)
409            ))
410        ));
411    }
412
413    #[test]
414    fn android31_reads_only_public_system_tones_and_derives_added_neutrals() {
415        let device = Color::from_uint32(0xff605d66);
416        let schemes = mapping::read(|name| {
417            if ![
418                "system_accent1_",
419                "system_accent2_",
420                "system_accent3_",
421                "system_neutral1_",
422                "system_neutral2_",
423            ]
424            .iter()
425            .any(|prefix| name.starts_with(prefix))
426            {
427                return None;
428            }
429            let suffix = name.rsplit('_').next().unwrap().parse::<u16>().unwrap();
430            assert!(
431                [0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000].contains(&suffix)
432            );
433            Some(device)
434        })
435        .unwrap();
436        assert_eq!(
437            schemes.source,
438            MaterialAndroidColorSource::Android31TonalPalette
439        );
440        assert_eq!(schemes.light.color(R::Primary), device);
441        assert_eq!(schemes.dark.color(R::Primary), device);
442        assert_ne!(schemes.light.color(R::Surface), device);
443        assert_ne!(schemes.dark.color(R::Surface), device);
444        assert_eq!(
445            schemes.light.color(R::Surface),
446            palette_math::set_luminance(device, 98)
447        );
448    }
449
450    #[test]
451    fn unavailable_dynamic_resources_report_a_failure() {
452        assert!(matches!(
453            mapping::read(|_| None),
454            Err(MaterialAndroidColorError::DynamicColorsUnavailable)
455        ));
456    }
457}