A shared launcher-icon generator for the Flutter apps

How five apps stopped shipping the stock Flutter icon — and why the interesting part is a measurement, not a drawing.

testsAndMisc 27f44ca · adoption commits in dufs-cloud 18a18a4, screen-locker 135545a, wake-alarm 64c25e6, diet-guard f6abf80, todo 0be0706

1. Background — what was there before

Five Flutter apps live across these repos: a file-server client (dufs_client), a gym tracker (workout_app), an alarm companion (wake_alarm_sync), a diet logger (diet_guard_app) and a notes app (todo). Each was scaffolded with flutter create, and none of them had ever been given an icon. That means every one of them shipped the identical stock Flutter artwork — five indistinguishable tiles on the same home screen.

What a Flutter project gives you by default

A fresh Android project contains only legacy icons: one PNG per density bucket, at android/app/src/main/res/mipmap-{m,h,xh,xxh,xxxh}dpi/ic_launcher.png. Nothing else. In particular there was no mipmap-anydpi-v26/ic_launcher.xml in any of the five repos — verified before starting.

That file matters more than its obscure name suggests. Since Android 8, launchers prefer an adaptive icon: instead of one flat bitmap, you supply a background layer and a foreground layer, and the launcher composites them and applies its own mask — a circle on one device, a squircle on another, a teardrop on a third. Without that XML the system falls back to the legacy PNG and just crops it into a shape, which is why untouched Flutter apps look slightly wrong on modern launchers. Android 13 added a third optional layer, <monochrome>, which the system recolours to match the user's wallpaper-derived theme.

The constraint nobody tells you about

An adaptive icon's layers are larger than the visible result. The launcher is only guaranteed to show roughly the inner 66% of the canvas; everything outside that can be masked away, and how much is device-dependent. So art that looks perfect at full size can lose a corner on a real phone.

Before

Five identical stock tiles; legacy PNGs only; no adaptive descriptor.

stock ×5

After

One family: shared charcoal field, per-app accent glyph, full adaptive layers.

The five apps before and after. Each accent is that app's own theme seed colour, so the icon matches the first screen you see when the app opens.

Why not just draw five icons?

Because "five icons" is a snapshot, and the sixth app is always coming. Hand-drawn one-offs drift: a slightly different stroke weight here, a different background there, and within a year there is no family left. The change therefore defines the family once, in code, and lets each app contribute only two things: an accent colour and a glyph.

2. Intuition — measure, then translate

The design rule is simple to state:

One charcoal field (#1B1D21), one stroke weight (72px on a 1024px canvas), one glyph per app in that app's own theme accent.

Stating it is easy. Making it true is where the interesting problem lives, and it is not a drawing problem — it is a measurement problem.

The problem: glyphs are not the same shape

Consider two glyphs drawn by hand on the same 1024×1024 canvas. A barbell is wide and short. A cloud-with-arrow is narrow and tall, and the author naturally drew the cloud near the top because that is where clouds go. Both are "roughly centred" in the sense that the author eyeballed them — but their ink sits at different heights. Put the finished icons in a launcher row and they visibly fail to line up.

The naive fix is to nudge coordinates by hand until it looks right. That works for five glyphs and collapses at the sixth, and it silently rots the moment anyone edits a path.

The fix: let the renderer find the ink

Instead, the glyph is rendered on its own on a transparent canvas, and the bounding box of the actual non-transparent pixels is measured. The offset from that box's centre to the canvas centre is then applied as a translation. The author never positions anything; they only decide the shape.

Two details make this work rather than merely sound clever:

Play with it

The widget below runs that pipeline step by step on the real glyph data from glyphs.py. Pick a glyph, then step through: draw it as authored, measure its ink box, compute the offset, apply it. Watch the safe-box verdict change as you go.

safe box 560

Three things are worth noticing while you play:

Three layers out, not one

Once a glyph is centred, three separate images are written from the same fragment. They differ only in what is drawn behind and in what colour:

background
flat colour
+
foreground
glyph, transparent
=
what you see
after masking
·
monochrome
Android 13 themed
The adaptive layers for diet_guard_app. The background is a flat colour declared in pubspec.yaml, not an image, so it can never mis-align with the foreground. The monochrome layer is the same glyph in white; the system tints it.

And on Linux, none of this applies

Two of the apps also build for Linux desktop, and there the whole Android mechanism is irrelevant — flutter_launcher_icons does not touch Linux at all. More surprisingly, Flutter's GTK runner never sets a window icon of its own: the generated my_application.cc sets a title and a default size and stops there, so the window inherits a generic placeholder.

GTK's idiomatic answer is not "point at a PNG file" but "name an icon and let the theme resolve it". So the runner asks for an icon by name, and a small install script puts PNGs under that name into the user's hicolor icon theme:

PieceRole
gtk_window_set_icon_name(window, "todo") The running window asks the theme for an icon called todo.
linux/icons/<size>/todo.png Pre-rendered and committed, so the installer needs no image tooling.
install_desktop_entry.sh Copies those into ~/.local/share/icons/hicolor/ and writes a .desktop entry.
StartupWMClass=dev.kuhy.todo Links the running window back to the launcher entry — see the trap below.
A trap worth remembering. StartupWMClass must match the window's actual WM_CLASS, and the natural guess — the binary name — is wrong. Running the built app and inspecting it with xprop -id <wid> WM_CLASS returned "dev.kuhy.todo": GTK derives it from the GApplication id in linux/CMakeLists.txt, not from the executable. Guessing here fails silently — the launcher entry looks right and the taskbar still shows a generic icon.

3. Code — a walkthrough by concern

The new package is python_pkg/app_icons/. Rather than walk the files alphabetically, here are the four concerns it separates.

Concern 1: the family, defined once

style.py holds every number that makes the icons a set. Changing anything here restyles all five apps at once, which is the point:

CANVAS: Final[int] = 1024
BACKGROUND: Final[str] = "#1B1D21"

# Android masks an adaptive icon down to roughly the inner 66% of the canvas.
SAFE_BOX: Final[int] = 560

# Glyph negative space must stay at or above STROKE_WIDTH / 2, otherwise
# adjacent strokes merge into a blob when scaled down to launcher size.
STROKE_WIDTH: Final[int] = 72
MIN_NEGATIVE_SPACE: Final[float] = STROKE_WIDTH / 2

That last constant encodes a mistake made during the work. The first fork glyph had its tines exactly 72px apart — one full stroke width — so the gaps between them closed to zero and the shape rendered as a solid blob. The rule is now written down next to the number that causes it.

Concern 2: measuring and drawing

render.py is where the centring lives. build_svg assembles a document by wrapping the glyph fragment in a group that carries the shared attributes plus the computed translation:

f'<g fill="none" stroke="{accent}" stroke-width="{style.STROKE_WIDTH}" '
f'stroke-linecap="round" stroke-linejoin="round" '
f'transform="translate({dx:g},{dy:g})">'

Because the shared attributes live on the wrapper, a glyph fragment is just paths — it inherits colour and weight and cannot accidentally disagree with the family. A fragment that genuinely needs something else (the filled cutlery silhouette) overrides locally and uses an {{ACCENT}} placeholder for its colour.

Then the measurement itself. Note that it renders, measures, and returns a translation — it never touches scale:

def centre_offset(glyph: Glyph, accent: str) -> tuple[float, float]:
    probe = build_svg(glyph, accent, with_background=False)
    with tempfile.TemporaryDirectory() as tmp:
        png = Path(tmp) / "probe.png"
        rasterise(probe, png, style.CANVAS)
        width, height, x, y = _ink_bbox(png)
    if width == 0 or height == 0:
        raise EmptyGlyphError(glyph.name)
    return style.CENTRE - (x + width / 2), style.CENTRE - (y + height / 2)

The probe is drawn without the background — a charcoal rectangle would fill the canvas and every measurement would return the canvas itself. _ink_bbox shells out to ImageMagick's -trim and parses the resulting geometry.

The sibling function safe_box_overflow reuses the same measurement for a different question — not "where is the ink" but "is there too much of it":

return max(0, width - style.SAFE_BOX), max(0, height - style.SAFE_BOX)

This is what turned an invisible problem into a visible one. On the first real run it reported three overflows, the worst being the cloud at 86px too tall. None of them were apparent at full size; all three would have been clipped on a phone.

Concern 3: the registry, and a checkout that lied

apps.py maps each app to its repo, accent and glyph. The accents are not invented — each is the app's own theme seed, so the launcher icon matches the app's UI:

AppAccentWhere it comes fromGlyph
dufs_client#5C6BC0colorSchemeSeed: Colors.indigocloud + arrow
workout_app#FF4444its own Color(0xFFFF4444)barbell
wake_alarm_sync#FFA726seedColor: Colors.orangeclock
diet_guard_app#26A69AcolorSchemeSeed: Colors.tealshield + cutlery
todo#7E57C2seedColor: Colors.deepPurplechecklist
One collision had to be resolved: dufs_client and workout_app both seed from Colors.indigo. The gym app already defined its own crimson accent, so it took crimson and indigo stayed unique.

The registry entry for dufs_client carries a warning comment, and it is worth explaining why:

key="dufs_client",
# The live app: ~/dufs_client is a stale, remote-less checkout that
# was superseded when dufs-cloud absorbed it (see testsAndMisc
# CLAUDE.md). Same package name, so it is easy to icon the wrong one.
repo=_HOME / "src/dufs-cloud" / "app",

There were two checkouts with the same name:, the same applicationId, and the same directory layout. Only one had a git remote and recent commits. The first pass targeted the wrong one — and because it also built and installed an APK from there, the phone briefly received a five-week-old build of a live app. The stale checkout has since been deleted; the comment exists so the trap cannot be re-entered.

Concern 4: handing off to the platform

generate.py writes the three layers, then delegates the fiddly part:

_LAYERS = (
    ("icon", True, None),                    # field + glyph
    ("icon_foreground", False, None),        # glyph only, transparent
    ("icon_monochrome", False, style.MONOCHROME),  # glyph only, white
)

Each layer is written as both SVG source and a 1024px PNG, so a build machine never needs rsvg-convert. flutter_launcher_icons then expands the PNGs into every density bucket and — the part worth outsourcing — writes the mipmap-anydpi-v26/ic_launcher.xml descriptor with all three layers wired up. Hand-rolling that for five apps is exactly the sort of repetitive work that goes wrong quietly.

A verification note. Every external tool (rsvg-convert, magick, dart) is mocked in the test suite, so the package reaches 100% line and branch coverage without any of them installed — but that also means the tests cannot tell you whether an icon looks right. That is what the preview subcommand is for: it renders a contact sheet with three rows — full size, circle-masked, and downscaled to 48dp — and the last word belongs to a screenshot of the actual phone launcher.

4. Quiz

Five questions on the reasoning behind the change. Click an option to check it. Score: 0 / 0