Skip to content

feat!: support android adaptive and ios layered icons - #40

Merged
trevor-lambert merged 3 commits into
mainfrom
feat/RDMR-1476/android-adaptive-icon
Sep 28, 2026
Merged

trevor-lambert merged 3 commits into
mainfrom
feat/RDMR-1476/android-adaptive-icon

Conversation

@trevor-lambert

@trevor-lambert trevor-lambert commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Description

Removes the AssetGenerator pipeline under src/project/assets/**, replaces it with a small, direct icon API under src/project/icons/**.

Why the asset/generator structure is gone

AssetGenerator + InputAsset + OutputAsset + a table of OutputAssetTemplate constants is the right shape when one source image has to fan out across a large, open-ended catalogue: icons and splash screens, light and dark, portrait and landscape, every density and idiom. That is not what this repo does. #41 removed the splash scaffolding and the unused kinds, and what was left was a 38-member AndroidDensity enum with six densities ever read, twelve template constants that differ only in a number, and a single generate() switching on five AssetKind values.

What remained cost more than it returned:

  • A caller had to construct an InputAsset (path + kind + platform), pick the matching generator subclass, and already know which AssetKind produced which files — AssetKind.Logo quietly meant "legacy icons and both adaptive layers".
  • OutputAsset, with its destFilenames / outputInfoMap maps, was built on every path and read by nothing in the repo.
  • The abstract base carried one method and an options bag of three fields, two of which only one platform honoured.

More importantly, the shape is what produced the adaptive-icon bugs. Because dispatch happened once per asset, every branch had to emit a complete result for its kind — which is why each adaptive-layer branch rewrote the entire <adaptive-icon> descriptor, and why two layer calls in sequence dropped each other's work. Because sizes came out of a filtered template table, adaptive layers were obtained by filtering AssetKind.Icon and casting the result, which is how foregrounds ended up at legacy dimensions. Because one InputAsset was meant to feed every template, a single Sharp instance was cloned across concurrent resizes, which Sharp does not allow. None of those were incidental slips; the abstraction made each one the natural thing to write, and fixing them inside it meant keeping the machinery that caused them.

The rest of src/project — strings, plists, xcconfig, gradle, the manifest — is plain functions and small classes that edit one thing and commit. Icons now work the same way: no base class, no templates, no result objects, one function per thing you can set.

What replaces it

Android — androidIcons:

Function Writes
generateLegacyIcons(source, project) mipmap-<density>/ic_launcher.png + ic_launcher_round.png, ldpi–xxxhdpi
setAdaptiveIconForeground(source, project, fit?) ic_launcher_foreground.png, mdpi–xxxhdpi, + the <foreground> element
setAdaptiveIconBackground(source, project, fit?) ic_launcher_background.png + the <background> element
setAdaptiveIconMonochrome(source, project, fit?) ic_launcher_monochrome.png + the <monochrome> element
setAdaptiveIconBackgroundColor(color, project) values/ic_launcher_background.xml + a @color <background>
clearAdaptiveIcon(project) removes both descriptors and the layer PNGs

iOS — iosIcons:

Function Writes
setAppIcon(source, project, backgroundColor?) the single 1024px AppIcon-512@2x.png in AppIcon.appiconset, transparency flattened onto backgroundColor (the App Store rejects an alpha channel)
setLayeredAppIcon(source, project) copies an Icon Composer .icon bundle to App/AppIcon.icon and registers it with the Xcode target

iOS 26 / Xcode 26 added layered app icons, authored in Icon Composer and stored as a .icon file package — a directory holding icon.json and an Assets/ folder. It is the iOS counterpart to the adaptive icon: a background fill plus ordered foreground layers, with dark and tinted appearances baked in. Trampoline installs an already-authored bundle verbatim; nothing here writes icon.json.

The two tiers are mutually exclusive. A target declares one ASSETCATALOG_COMPILER_APPICON_NAME, so a .icon and an .appiconset cannot both answer to AppIcon — setting either clears the other. actool back-deploys flattened icons from the bundle, so older iOS stays covered. Building a project with a .icon needs Xcode 26+.

Both are set* because they fill the same slot. generateLegacyIcons keeps generate* because on Android the legacy and adaptive tiers coexist — they are not alternatives.

Every function writes its files, points the manifest at them, and commits, so calls are independent and a caller never has to remember a follow-up step.

Change Type

  • Fix
  • Feature
  • Refactor
  • Breaking Change
  • Documentation
  • Other (CI, chores, etc.)

Rationale / Problems Fixed

Adaptive icon bugs fixed

  • Layers were generated at legacy sizes. The foreground/background paths filtered AssetKind.Icon templates and cast them to adaptive templates, so ic_launcher_foreground.png was written at 192px at xxxhdpi instead of the 432px the 108dp canvas needs.
  • The background rendered black at the edges. The descriptor wrapped it in <inset android:inset="16.7%">, leaving the outer 18dp of the 108dp canvas transparent and compositing onto AdaptiveIconDrawable's black canvas — visible during wallpaper-peek and launch animations. Layers are now written at full canvas size with no inset wrapper, and an existing 0.5.0 wrapper is normalised away rather than having its drawable swapped inside a wrapper that keeps shrinking it.
  • No <monochrome> support, so no Android 13+ themed icons.
  • A shared Sharp instance was reused across concurrent per-density resizes, which Sharp does not allow (Pseudo error: Image to composite must have same dimensions or smaller. lovell/sharp#2378). Every resize now builds its own pipeline.
  • Non-square sources were center-cropped. Sharp defaults to fit: cover; resizes now pass fit: contain, so the ends of a wordmark survive.

Legacy icon bugs fixed

  • The round launcher icon was a square, and the padding was black, for any alpha-less source. The intermediate .toBuffer() calls omitted .png(), so the buffer kept the source's encoding. A JPEG has no alpha channel, so the transparent bands fit: contain creates were flattened to black on the way out, extend({background: TRANSPARENT}) had no alpha to write a transparent border into, and composite({blend: 'dest-in'}) — which makes the icon round by writing the circle into the alpha channel — silently did nothing. A PNG source hid all of it. writeLayerImage already did this correctly; only the two legacy functions did not.
  • Legacy icon padding was a constant 8px at every density, so the artwork filled 55% of the canvas at ldpi and 92% at xxxhdpi — the same icon at visibly different sizes depending on the device's bucket. It is now size / 12, holding ~83% at every density. That matches the template's own shipped icons (12px at xxhdpi, 16px at xxxhdpi) and leaves xhdpi unchanged at 8px, which is the density the frozen constant was right for.

Registering a .icon with the Xcode project

A .icon is a directory that Xcode treats as one file: pbxproj type folder.iconcomposer.icon (UTI com.apple.iconcomposer.icon), a single file reference in the Resources build phase, not expanded. IosProject gained addResourceFile() / removeResourceFile() for this. Four things that are not obvious:

  • The file type has to be passed explicitly. xcode's detectType() knows a handful of extensions and calls everything else unknown. A .icon labelled that way is copied into the bundle as an opaque directory and never reaches actool — no icon, and no build error.
  • pbxProject.addResourceFile() is unusable here. It routes through the library's private correctForResourcesPath(), which dereferences pbxGroupByName('Resources') with no null check. The Capacitor template has no Resources group, so the library's own entry point throws a TypeError on every Capacitor project. The public primitives it wraps are called directly instead. (shell-handler independently hand-rolled the same workaround for its resource copying.)
  • The emitted file reference is pruned. It always carries a fixed key set whether or not the file has values for them, and the writer only drops empty ones under omitEmptyValues, which would change how the whole project serializes — so that one object is pruned, or the pbxproj gains literal fileEncoding = undefined; lines.
  • Registration unregisters first, then re-adds, rather than bailing when a reference already exists. That keeps repeat calls idempotent, and it repairs a reference that was never added to the build phase — a bundle dragged into Xcode with "Add to targets" unchecked — which would otherwise satisfy a dedupe check while never reaching actool.

Descriptor handling

AdaptiveIconDescriptor treats mipmap-anydpi-v26/ic_launcher.xml and ic_launcher_round.xml as documents rather than strings, and each write replaces exactly one element. That cuts both ways: the layers you did not set are left alone, and a layer added by hand or by another tool survives. clearAdaptiveIcon() deletes the descriptors rather than emptying them — a childless <adaptive-icon> is valid and renders nothing, where a missing one falls back to the legacy PNGs.

A solid background is now a @color/ic_launcher_background resource instead of a generated PNG, and the color node is always written alongside the reference, so the descriptor can never point at a missing resource (which aapt2 fails the build over).

XML reads go through a helper that asserts the parsed root element. XmlFile.load() never rejects — a parse failure logs and leaves an empty document, an empty file becomes <root /> — after which every xpath matches nothing and edits are silently dropped while the caller is told the write succeeded.

LayerFit

Layer functions take an optional fit:

  • as-is (default) — authored 108dp artwork that already carries its own padding, resized to the canvas untouched.
  • viewport — full-bleed artwork that is the whole icon, scaled into 76/108. The mask draws the central 72dp but can expose up to 74.25dp, so the art bleeds to 76dp.

Deliberately not Google's 66dp safe zone: that is the rule for a bare mark surviving a circular mask, and applying it to full-bleed artwork leaves a ring of background showing.

Also in here

XmlFile.replaceFragment() inserted only documentElement, so a multi-element fragment lost all but its first element. It now inserts every parsed element, matching injectFragment() (fixed in 30ae8d4). The fragment is still parsed per match, because insertBefore moves nodes rather than copying them.

Breaking Changes

Everything exported from src/project/assets/** is removed: AssetGenerator, AssetGeneratorOptions, AndroidAssetGenerator, IosAssetGenerator, InputAsset, OutputAsset, and the AssetKind, Platform, Format, AndroidDensity, IosIdiom enums and *OutputAssetTemplate interfaces.

Before After
new AndroidAssetGenerator().generate(new InputAsset(p, AssetKind.Icon, Platform.Android), project) androidIcons.generateLegacyIcons(p, project)
...AssetKind.IconForeground... androidIcons.setAdaptiveIconForeground(p, project, fit?)
...AssetKind.IconBackground... androidIcons.setAdaptiveIconBackground(p, project, fit?) or setAdaptiveIconBackgroundColor(color, project)
...AssetKind.Logo... (icon + adaptive layers in one call) call generateLegacyIcons and the layer setters you want
new IosAssetGenerator().generate(new InputAsset(p, AssetKind.Icon, Platform.Ios), project) iosIcons.setAppIcon(p, project, backgroundColor?)
iosIcons.generateAppIcon(p, project, backgroundColor?) iosIcons.setAppIcon(p, project, backgroundColor?)

AssetGeneratorOptions is gone with it, so androidFlavor, iconBackgroundColor and iconBackgroundColorDark no longer exist; the iOS background color is now an argument to setAppIcon. Calls return the paths written (string[]) instead of OutputAsset[]. Android resources go to getResourcesRoot() like the rest of the Android helpers, i.e. app/src/main/res, so a non-main product flavor can no longer be targeted.

iosIcons.generateAppIcon is renamed iosIcons.setAppIcon, so that both iOS tiers read as filling the one app-icon slot they share.

Other behavior changes:

  • A foreground passed with fit: 'viewport' is scaled to 76/108, where it previously filled the canvas and was inset by the descriptor.
  • setAppIcon now clears any layered icon, and setLayeredAppIcon deletes the app icon set — the two cannot coexist under one ASSETCATALOG_COMPILER_APPICON_NAME. setAppIcon also commits, and points that build setting at AppIcon.
  • Legacy launcher icons change size at every density except xhdpi, from the padding fix above.

Tests or Reproductions

The repo has no test infrastructure, so this was validated by hand against real projects.

iOS layered icons — against the Capacitor ios-spm-template and ios-pods-template, and the OutSystems ios-template and ios-template-spm, with a .icon bundle authored in Icon Composer:

  • xcodebuild with Xcode 26.3 succeeds; actool receives the .icon alongside Assets.xcassets with --app-icon AppIcon.
  • assetutil --info on the built Assets.car shows AppIcon.iconstack for UIAppearanceLight, UIAppearanceDark and ISAppearanceTintable, the SVG layers as vectors, and the back-deployed AppIcon60x60@2x.png / AppIcon76x76@2x~ipad.png that actool derives from the bundle. No raw .icon ships in the app.
  • Running it twice leaves exactly one pbxproj registration; xcodebuild -list still parses the project; no = undefined; is written.
  • Round trip: layered → flat → layered leaves no stale bundle, no orphaned pbxproj references, and a rebuilt Contents.json.
  • Pointing it at a plain file, a directory with no icon.json, or a missing path all throw before anything is copied.

Legacy icon fixes — generating from a JPEG source now yields channels=4 hasAlpha=true, a round icon whose centre is opaque and whose corners are transparent, and ~83% artwork at all six densities (previously 55%–92%).

Screenshots / Media

Platforms Affected

  • Android
  • iOS
  • Web

Notes / Comments

The .icon half of the caveat this PR originally noted is now resolved: dark and tinted appearances are expressed by the layered bundle, so setLayeredAppIcon is the path for them rather than coexisting entries in the asset catalog.

What remains, still documented in icons/ios.ts: setAppIcon drops every other image the catalog listed and deletes the files. That is unchanged pre-existing behaviour for the flat tier. updateContentsJson now rebuilds a missing Contents.json rather than rejecting, since installing a layered icon deletes the set.

declarations.d.ts declares xcode/lib/pbxFile so the library's own file constructor can be used directly. It is a deliberate reach into an internal path, safe because xcode@3.0.1 publishes no exports map — and if that ever changes it breaks at compile time rather than silently.

@trevor-lambert
trevor-lambert force-pushed the feat/RDMR-1476/android-adaptive-icon branch from 4b6d4f2 to f6dfdad Compare September 25, 2026 20:14
@trevor-lambert trevor-lambert changed the title RDMR-1476 - Support Android Adaptive Icons RDMR-1476 - Redesign icon generation and support Adaptive Icons Sep 25, 2026
@trevor-lambert trevor-lambert changed the title RDMR-1476 - Redesign icon generation and support Adaptive Icons RDMR-1476 - Redesign icon generation and support Android adaptive and iOS layered icons Sep 26, 2026
@trevor-lambert trevor-lambert changed the title RDMR-1476 - Redesign icon generation and support Android adaptive and iOS layered icons feat!: support android adaptive and ios layered icons Sep 26, 2026
@trevor-lambert
trevor-lambert merged commit 1df0592 into main Sep 28, 2026
2 checks passed
@trevor-lambert
trevor-lambert deleted the feat/RDMR-1476/android-adaptive-icon branch September 28, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants