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
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.
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.
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.
Five identical stock tiles; legacy PNGs only; no adaptive descriptor.
One family: shared charcoal field, per-app accent glyph, full adaptive layers.
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.
The design rule is simple to state:
#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.
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.
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:
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.
Three things are worth noticing while you play:
x=268, the leftmost path coordinate, because 36px of stroke
half-width plus a round cap is real ink. This is precisely why the pipeline measures a
rasterised image instead of reading path geometry.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:
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.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:
| Piece | Role |
|---|---|
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. |
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.
The new package is python_pkg/app_icons/. Rather than walk the files
alphabetically, here are the four concerns it separates.
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.
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.
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:
| App | Accent | Where it comes from | Glyph |
|---|---|---|---|
| dufs_client | #5C6BC0 | colorSchemeSeed: Colors.indigo | cloud + arrow |
| workout_app | #FF4444 | its own Color(0xFFFF4444) | barbell |
| wake_alarm_sync | #FFA726 | seedColor: Colors.orange | clock |
| diet_guard_app | #26A69A | colorSchemeSeed: Colors.teal | shield + cutlery |
| todo | #7E57C2 | seedColor: Colors.deepPurple | checklist |
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.
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.
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.
Five questions on the reasoning behind the change. Click an option to check it. Score: 0 / 0