1use crate::{MaterialDynamicColorError, MaterialTheme};
11use std::fmt;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum MaterialAndroidColorSource {
16 Android31TonalPalette,
18 Android34ColorRoles,
20}
21
22#[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 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 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 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 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 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 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}