Add support for adaptive and themed icons on android (#14223)

* add support for adaptive icons

* fix

* small cleanup

* combine android_bg and android_fg when specified

* Update crates/tauri-cli/src/icon.rs

Co-authored-by: Fabian-Lars <github@fabianlars.de>

* add scale option

* properly generated rounded icons

* covector, clippy

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
Co-authored-by: Fabian-Lars <github@fabianlars.de>
This commit is contained in:
kandrelczyk 2025-10-01 14:41:54 +02:00 committed by GitHub
parent 673867aa0e
commit 94cbd40fc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 514 additions and 104 deletions

View File

@ -0,0 +1,5 @@
---
'tauri-cli': 'patch:enhance'
---
Add support for Android's adaptive and themed icons.

View File

@ -22,7 +22,7 @@ use image::{
png::{CompressionType, FilterType as PngFilterType, PngEncoder},
},
imageops::FilterType,
open, DynamicImage, ExtendedColorType, ImageBuffer, ImageEncoder, Rgba,
open, DynamicImage, ExtendedColorType, GenericImageView, ImageBuffer, ImageEncoder, Rgba,
};
use resvg::{tiny_skia, usvg};
use serde::Deserialize;
@ -40,10 +40,48 @@ struct PngEntry {
out_path: PathBuf,
}
enum AndroidIconKind {
Regular,
Rounded,
}
struct AndroidEntries {
icon: Vec<(PngEntry, AndroidIconKind)>,
foreground: Vec<PngEntry>,
background: Vec<PngEntry>,
monochrome: Vec<PngEntry>,
}
#[derive(Deserialize)]
struct Manifest {
default: String,
bg_color: Option<String>,
android_bg: Option<String>,
android_fg: Option<String>,
android_monochrome: Option<String>,
android_fg_scale: Option<f32>,
}
#[derive(Debug, Parser)]
#[clap(about = "Generate various icons for all major platforms")]
pub struct Options {
/// Path to the source icon (squared PNG or SVG file with transparency).
/// Path to the source icon (squared PNG or SVG file with transparency) or a manifest file.
///
/// The manifest file is a JSON file with the following structure:
/// {
/// "default": "app-icon.png",
/// "bg_color": "#fff",
/// "android_bg": "app-icon-bg.png",
/// "android_fg": "app-icon-fg.png",
/// "android_fg_scale": 85,
/// "android_monochrome": "app-icon-monochrome.png"
/// }
///
/// All file paths defined in the manifest JSON are relative to the manifest file path.
///
/// Only the `default` manifest property is required.
///
/// The `bg_color` manifest value overwrites the `--ios-color` option if set.
#[clap(default_value = "./app-icon.png")]
input: PathBuf,
/// Output directory.
@ -60,6 +98,7 @@ pub struct Options {
ios_color: String,
}
#[derive(Clone)]
#[allow(clippy::large_enum_variant)]
enum Source {
Svg(resvg::usvg::Tree),
@ -99,14 +138,41 @@ impl Source {
}
}
pub fn command(options: Options) -> Result<()> {
let input = options.input;
let out_dir = options.output.unwrap_or_else(|| {
crate::helpers::app_paths::resolve();
tauri_dir().join("icons")
});
let png_icon_sizes = options.png.unwrap_or_default();
let ios_color = css_color::Srgb::from_str(&options.ios_color)
fn read_source(path: PathBuf) -> Result<Source> {
if let Some(extension) = path.extension() {
if extension == "svg" {
let rtree = {
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_system_fonts();
let opt = usvg::Options {
// Get file's absolute directory.
resources_dir: std::fs::canonicalize(&path)
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf())),
fontdb: Arc::new(fontdb),
..Default::default()
};
let svg_data = std::fs::read(&path).unwrap();
usvg::Tree::from_data(&svg_data, &opt).unwrap()
};
Ok(Source::Svg(rtree))
} else {
Ok(Source::DynamicImage(DynamicImage::ImageRgba8(
open(&path)
.context(format!("Can't read and decode source image: {:?}", path))?
.into_rgba8(),
)))
}
} else {
anyhow::bail!("Error loading image");
}
}
fn parse_bg_color(bg_color_string: &String) -> Result<Rgba<u8>> {
let bg_color = css_color::Srgb::from_str(bg_color_string)
.map(|color| {
Rgba([
(color.red * 255.) as u8,
@ -115,41 +181,44 @@ pub fn command(options: Options) -> Result<()> {
(color.alpha * 255.) as u8,
])
})
.map_err(|_| anyhow::anyhow!("failed to parse iOS color"))?;
.map_err(|_| anyhow::anyhow!("failed to parse color {}", bg_color_string))?;
Ok(bg_color)
}
pub fn command(options: Options) -> Result<()> {
let input = options.input;
let out_dir = options.output.unwrap_or_else(|| {
crate::helpers::app_paths::resolve();
tauri_dir().join("icons")
});
let png_icon_sizes = options.png.unwrap_or_default();
create_dir_all(&out_dir).context("Can't create output directory")?;
let source = if let Some(extension) = input.extension() {
if extension == "svg" {
let rtree = {
let mut fontdb = usvg::fontdb::Database::new();
fontdb.load_system_fonts();
let opt = usvg::Options {
// Get file's absolute directory.
resources_dir: std::fs::canonicalize(&input)
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf())),
fontdb: Arc::new(fontdb),
..Default::default()
};
let svg_data = std::fs::read(&input).unwrap();
usvg::Tree::from_data(&svg_data, &opt).unwrap()
};
Source::Svg(rtree)
} else {
Source::DynamicImage(DynamicImage::ImageRgba8(
open(&input)
.context("Can't read and decode source image")?
.into_rgba8(),
))
}
let manifest = if input.extension().is_some_and(|ext| ext == "json") {
parse_manifest(&input).map(Some)?
} else {
anyhow::bail!("Error loading image");
None
};
let bg_color_string = match manifest {
Some(ref manifest) => manifest
.bg_color
.as_ref()
.unwrap_or(&options.ios_color)
.clone(),
None => options.ios_color,
};
let bg_color = parse_bg_color(&bg_color_string)?;
let default_icon = match manifest {
Some(ref manifest) => input.parent().unwrap().join(manifest.default.clone()),
None => input.clone(),
};
let source = read_source(default_icon)?;
if source.height() != source.width() {
anyhow::bail!("Source image must be square");
}
@ -159,7 +228,9 @@ pub fn command(options: Options) -> Result<()> {
icns(&source, &out_dir).context("Failed to generate .icns file")?;
ico(&source, &out_dir).context("Failed to generate .ico file")?;
png(&source, &out_dir, ios_color).context("Failed to generate png icons")?;
png(&source, &out_dir, bg_color).context("Failed to generate png icons")?;
android(&source, &input, manifest, &bg_color_string, &out_dir)
.context("Failed to generate android icons")?;
} else {
for target in png_icon_sizes
.into_iter()
@ -175,22 +246,32 @@ pub fn command(options: Options) -> Result<()> {
.collect::<Vec<PngEntry>>()
{
log::info!(action = "PNG"; "Creating {}", target.name);
resize_and_save_png(&source, target.size, &target.out_path, None)?;
resize_and_save_png(&source, target.size, &target.out_path, None, None)?;
}
}
Ok(())
}
fn parse_manifest(manifest_path: &Path) -> Result<Manifest> {
let manifest: Manifest = serde_json::from_str(
&std::fs::read_to_string(manifest_path)
.with_context(|| format!("cannot read manifest file {}", manifest_path.display()))?,
)
.with_context(|| format!("failed to parse manifest file {}", manifest_path.display()))?;
log::debug!("Read manifest file from {}", manifest_path.display());
Ok(manifest)
}
fn appx(source: &Source, out_dir: &Path) -> Result<()> {
log::info!(action = "Appx"; "Creating StoreLogo.png");
resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None)?;
resize_and_save_png(source, 50, &out_dir.join("StoreLogo.png"), None, None)?;
for size in [30, 44, 71, 89, 107, 142, 150, 284, 310] {
let file_name = format!("Square{size}x{size}Logo.png");
log::info!(action = "Appx"; "Creating {}", file_name);
resize_and_save_png(source, size, &out_dir.join(&file_name), None)?;
resize_and_save_png(source, size, &out_dir.join(&file_name), None, None)?;
}
Ok(())
@ -268,38 +349,20 @@ fn ico(source: &Source, out_dir: &Path) -> Result<()> {
Ok(())
}
// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png)
// Main target: Linux
fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
let mut entries = Vec::new();
for size in [32, 64, 128, 256, 512] {
let file_name = match size {
256 => "128x128@2x.png".to_string(),
512 => "icon.png".to_string(),
_ => format!("{size}x{size}.png"),
};
entries.push(PngEntry {
out_path: out_dir.join(&file_name),
name: file_name,
size,
});
}
entries
}
fn android_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
fn android(
source: &Source,
input: &Path,
manifest: Option<Manifest>,
bg_color: &String,
out_dir: &Path,
) -> Result<()> {
fn android_entries(out_dir: &Path) -> Result<AndroidEntries> {
struct AndroidEntry {
name: &'static str,
size: u32,
foreground_size: u32,
}
let mut entries = Vec::new();
let targets = vec![
AndroidEntry {
name: "hdpi",
@ -327,6 +390,10 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
foreground_size: 432,
},
];
let mut icon_entries = Vec::new();
let mut fg_entries = Vec::new();
let mut bg_entries = Vec::new();
let mut monochrome_entries = Vec::new();
for target in targets {
let folder_name = format!("mipmap-{}", target.name);
@ -334,24 +401,204 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
create_dir_all(&out_folder).context("Can't create Android mipmap output directory")?;
entries.push(PngEntry {
fg_entries.push(PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher_foreground.png"),
out_path: out_folder.join("ic_launcher_foreground.png"),
size: target.foreground_size,
});
entries.push(PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
out_path: out_folder.join("ic_launcher_round.png"),
size: target.size,
icon_entries.push((
PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher_round.png"),
out_path: out_folder.join("ic_launcher_round.png"),
size: target.size,
},
AndroidIconKind::Rounded,
));
icon_entries.push((
PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher.png"),
out_path: out_folder.join("ic_launcher.png"),
size: target.size,
},
AndroidIconKind::Regular,
));
bg_entries.push(PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher_background.png"),
out_path: out_folder.join("ic_launcher_background.png"),
size: target.foreground_size,
});
entries.push(PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher.png"),
out_path: out_folder.join("ic_launcher.png"),
size: target.size,
monochrome_entries.push(PngEntry {
name: format!("{}/{}", folder_name, "ic_launcher_monochrome.png"),
out_path: out_folder.join("ic_launcher_monochrome.png"),
size: target.foreground_size,
});
}
Ok(entries)
Ok(AndroidEntries {
icon: icon_entries,
foreground: fg_entries,
background: bg_entries,
monochrome: monochrome_entries,
})
}
fn create_color_file(out_dir: &Path, color: &String) -> Result<()> {
let values_folder = out_dir.join("values");
create_dir_all(&values_folder).context("Can't create Android values output directory")?;
let mut color_file = File::create(values_folder.join("ic_launcher_background.xml"))?;
color_file.write_all(
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">{}</color>
</resources>"#,
color
)
.as_bytes(),
)?;
Ok(())
}
let android_out = out_dir
.parent()
.unwrap()
.join("gen/android/app/src/main/res/");
let out = if android_out.exists() {
android_out
} else {
let out = out_dir.join("android");
create_dir_all(&out).context("Can't create Android output directory")?;
out
};
let entries = android_entries(&out)?;
let fg_source = match manifest {
Some(ref manifest) => {
Some(read_source(input.parent().unwrap().join(
manifest.android_fg.as_ref().unwrap_or(&manifest.default),
))?)
}
None => None,
};
for entry in entries.foreground {
log::info!(action = "Android"; "Creating {}", entry.name);
resize_and_save_png(
fg_source.as_ref().unwrap_or(source),
entry.size,
&entry.out_path,
None,
None,
)?;
}
let mut bg_source = None;
let mut has_monochrome_image = false;
if let Some(ref manifest) = manifest {
if let Some(ref background_path) = manifest.android_bg {
let bg = read_source(input.parent().unwrap().join(background_path))?;
for entry in entries.background {
log::info!(action = "Android"; "Creating {}", entry.name);
resize_and_save_png(&bg, entry.size, &entry.out_path, None, None)?;
}
bg_source.replace(bg);
}
if let Some(ref monochrome_path) = manifest.android_monochrome {
has_monochrome_image = true;
let mc = read_source(input.parent().unwrap().join(monochrome_path))?;
for entry in entries.monochrome {
log::info!(action = "Android"; "Creating {}", entry.name);
resize_and_save_png(&mc, entry.size, &entry.out_path, None, None)?;
}
}
}
for (entry, kind) in entries.icon {
log::info!(action = "Android"; "Creating {}", entry.name);
let (margin, radius) = match kind {
AndroidIconKind::Regular => {
let radius = ((entry.size as f32) * 0.0833).round() as u32;
(radius, radius)
}
AndroidIconKind::Rounded => {
let margin = ((entry.size as f32) * 0.04).round() as u32;
let radius = ((entry.size as f32) * 0.5).round() as u32;
(margin, radius)
}
};
let image = if let (Some(bg_source), Some(fg_source)) = (bg_source.as_ref(), fg_source.as_ref())
{
resize_png(
fg_source,
entry.size,
Some(Background::Image(bg_source)),
manifest
.as_ref()
.and_then(|manifest| manifest.android_fg_scale),
)?
} else {
resize_png(source, entry.size, None, None)?
};
let image = apply_round_mask(&image, entry.size, margin, radius);
let mut out_file = BufWriter::new(File::create(entry.out_path)?);
write_png(image.as_bytes(), &mut out_file, entry.size)?;
out_file.flush()?;
}
let mut launcher_content = r#"<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>"#
.to_owned();
if bg_source.is_some() {
launcher_content
.push_str("\n <background android:drawable=\"@mipmap/ic_launcher_background\"/>");
} else {
create_color_file(&out, bg_color)?;
launcher_content
.push_str("\n <background android:drawable=\"@color/ic_launcher_background\"/>");
}
if has_monochrome_image {
launcher_content
.push_str("\n <monochrome android:drawable=\"@mipmap/ic_launcher_monochrome\"/>");
}
launcher_content.push_str("\n</adaptive-icon>");
let any_dpi_folder = out.join("mipmap-anydpi-v26");
create_dir_all(&any_dpi_folder)
.context("Can't create Android mipmap-anydpi-v26 output directory")?;
let mut launcher_file = File::create(any_dpi_folder.join("ic_launcher.xml"))?;
launcher_file.write_all(launcher_content.as_bytes())?;
Ok(())
}
// Generate .png files in 32x32, 64x64, 128x128, 256x256, 512x512 (icon.png)
// Main target: Linux
fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
fn desktop_entries(out_dir: &Path) -> Vec<PngEntry> {
let mut entries = Vec::new();
for size in [32, 64, 128, 256, 512] {
let file_name = match size {
256 => "128x128@2x.png".to_string(),
512 => "icon.png".to_string(),
_ => format!("{size}x{size}.png"),
};
entries.push(PngEntry {
out_path: out_dir.join(&file_name),
name: file_name,
size,
});
}
entries
}
fn ios_entries(out_dir: &Path) -> Result<Vec<PngEntry>> {
@ -428,20 +675,7 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
Ok(entries)
}
let mut entries = desktop_entries(out_dir);
let android_out = out_dir
.parent()
.unwrap()
.join("gen/android/app/src/main/res/");
let out = if android_out.exists() {
android_out
} else {
let out = out_dir.join("android");
create_dir_all(&out).context("Can't create Android output directory")?;
out
};
entries.extend(android_entries(&out)?);
let entries = desktop_entries(out_dir);
let ios_out = out_dir
.parent()
@ -457,32 +691,73 @@ fn png(source: &Source, out_dir: &Path, ios_color: Rgba<u8>) -> Result<()> {
for entry in entries {
log::info!(action = "PNG"; "Creating {}", entry.name);
resize_and_save_png(source, entry.size, &entry.out_path, None)?;
resize_and_save_png(source, entry.size, &entry.out_path, None, None)?;
}
for entry in ios_entries(&out)? {
log::info!(action = "iOS"; "Creating {}", entry.name);
resize_and_save_png(source, entry.size, &entry.out_path, Some(ios_color))?;
resize_and_save_png(
source,
entry.size,
&entry.out_path,
Some(Background::Color(ios_color)),
None,
)?;
}
Ok(())
}
enum Background<'a> {
Color(Rgba<u8>),
Image(&'a Source),
}
// Resize image.
fn resize_png(
source: &Source,
size: u32,
bg: Option<Background>,
scale_percent: Option<f32>,
) -> Result<DynamicImage> {
let mut image = source.resize_exact(size)?;
match bg {
Some(Background::Color(bg_color)) => {
let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color);
let fg = scale_percent
.map(|scale| resize_asset(&image, size, scale))
.unwrap_or(image);
image::imageops::overlay(&mut bg_img, &fg, 0, 0);
image = bg_img.into();
}
Some(Background::Image(bg_source)) => {
let mut bg = bg_source.resize_exact(size)?;
let fg = scale_percent
.map(|scale| resize_asset(&image, size, scale))
.unwrap_or(image);
image::imageops::overlay(&mut bg, &fg, 0, 0);
image = bg;
}
None => {}
}
Ok(image)
}
// Resize image and save it to disk.
fn resize_and_save_png(
source: &Source,
size: u32,
file_path: &Path,
bg_color: Option<Rgba<u8>>,
bg: Option<Background>,
scale_percent: Option<f32>,
) -> Result<()> {
let mut image = source.resize_exact(size)?;
if let Some(bg_color) = bg_color {
let mut bg_img = ImageBuffer::from_fn(size, size, |_, _| bg_color);
image::imageops::overlay(&mut bg_img, &image, 0, 0);
image = bg_img.into();
}
let image = resize_png(source, size, bg, scale_percent)?;
let mut out_file = BufWriter::new(File::create(file_path)?);
write_png(image.as_bytes(), &mut out_file, size)?;
Ok(out_file.flush()?)
@ -494,3 +769,133 @@ fn write_png<W: Write>(image_data: &[u8], w: W, size: u32) -> Result<()> {
encoder.write_image(image_data, size, size, ExtendedColorType::Rgba8)?;
Ok(())
}
// finds the bounding box of non-transparent pixels in an RGBA image.
fn content_bounds(img: &DynamicImage) -> Option<(u32, u32, u32, u32)> {
let rgba = img.to_rgba8();
let (width, height) = img.dimensions();
let mut min_x = width;
let mut min_y = height;
let mut max_x = 0;
let mut max_y = 0;
let mut found = false;
for y in 0..height {
for x in 0..width {
let a = rgba.get_pixel(x, y)[3];
if a > 0 {
found = true;
if x < min_x {
min_x = x;
}
if y < min_y {
min_y = y;
}
if x > max_x {
max_x = x;
}
if y > max_y {
max_y = y;
}
}
}
}
if found {
Some((min_x, min_y, max_x - min_x + 1, max_y - min_y + 1))
} else {
None
}
}
fn resize_asset(img: &DynamicImage, target_size: u32, scale_percent: f32) -> DynamicImage {
let cropped = if let Some((x, y, cw, ch)) = content_bounds(img) {
img.crop_imm(x, y, cw, ch)
} else {
img.clone()
};
let (cw, ch) = cropped.dimensions();
let max_dim = cw.max(ch) as f32;
let scale = (target_size as f32 * (scale_percent / 100.0)) / max_dim;
let new_w = (cw as f32 * scale).round() as u32;
let new_h = (ch as f32 * scale).round() as u32;
let resized = image::imageops::resize(&cropped, new_w, new_h, image::imageops::Lanczos3);
// Place on transparent square canvas
let mut canvas = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0]));
let offset_x = if new_w > target_size {
// Image wider than canvas → start at negative offset
-((new_w - target_size) as i32 / 2)
} else {
(target_size - new_w) as i32 / 2
};
let offset_y = if new_h > target_size {
-((new_h - target_size) as i32 / 2)
} else {
(target_size - new_h) as i32 / 2
};
image::imageops::overlay(&mut canvas, &resized, offset_x.into(), offset_y.into());
DynamicImage::ImageRgba8(canvas)
}
fn apply_round_mask(
img: &DynamicImage,
target_size: u32,
margin: u32,
radius: u32,
) -> DynamicImage {
// Clamp radius to half of inner size
let inner_size = target_size.saturating_sub(2 * margin);
let radius = radius.min(inner_size / 2);
// Resize inner image to fit inside margins
let resized = img.resize_exact(inner_size, inner_size, image::imageops::Lanczos3);
// Prepare output canvas
let mut out = ImageBuffer::from_pixel(target_size, target_size, Rgba([0, 0, 0, 0]));
// Draw the resized image at (margin, margin)
image::imageops::overlay(&mut out, &resized, margin as i64, margin as i64);
// Apply rounded corners
for y in 0..target_size {
for x in 0..target_size {
let inside = if x >= margin + radius
&& x < target_size - margin - radius
&& y >= margin + radius
&& y < target_size - margin - radius
{
true // inside central rectangle
} else {
// Determine corner centers
let (cx, cy) = if x < margin + radius && y < margin + radius {
(margin + radius, margin + radius) // top-left
} else if x >= target_size - margin - radius && y < margin + radius {
(target_size - margin - radius, margin + radius) // top-right
} else if x < margin + radius && y >= target_size - margin - radius {
(margin + radius, target_size - margin - radius) // bottom-left
} else if x >= target_size - margin - radius && y >= target_size - margin - radius {
(target_size - margin - radius, target_size - margin - radius) // bottom-right
} else {
continue; // edges that are not corners are inside
};
let dx = x as i32 - cx as i32;
let dy = y as i32 - cy as i32;
dx * dx + dy * dy <= (radius as i32 * radius as i32)
};
if !inside {
out.put_pixel(x, y, Rgba([0, 0, 0, 0]));
}
}
}
DynamicImage::ImageRgba8(out)
}