Repository navigation
feat!: support android adaptive and ios layered icons - #40
Merged
Merged
Conversation
trevor-lambert
force-pushed
the
feat/RDMR-1476/android-adaptive-icon
branch
from
September 25, 2026 20:14
4b6d4f2 to
f6dfdad
Compare
trevor-lambert
requested review from
Chuckytuh,
EiyuuZack,
OS-ruialves,
benmccarty91 and
usernuno
September 26, 2026 19:03
benmccarty91
approved these changes
Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Removes the
AssetGeneratorpipeline undersrc/project/assets/**, replaces it with a small, direct icon API undersrc/project/icons/**.Why the asset/generator structure is gone
AssetGenerator+InputAsset+OutputAsset+ a table ofOutputAssetTemplateconstants 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-memberAndroidDensityenum with six densities ever read, twelve template constants that differ only in a number, and a singlegenerate()switching on fiveAssetKindvalues.What remained cost more than it returned:
InputAsset(path + kind + platform), pick the matching generator subclass, and already know whichAssetKindproduced which files —AssetKind.Logoquietly meant "legacy icons and both adaptive layers".OutputAsset, with itsdestFilenames/outputInfoMapmaps, was built on every path and read by nothing in the repo.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 filteringAssetKind.Iconand casting the result, which is how foregrounds ended up at legacy dimensions. Because oneInputAssetwas 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:generateLegacyIcons(source, project)mipmap-<density>/ic_launcher.png+ic_launcher_round.png, ldpi–xxxhdpisetAdaptiveIconForeground(source, project, fit?)ic_launcher_foreground.png, mdpi–xxxhdpi, + the<foreground>elementsetAdaptiveIconBackground(source, project, fit?)ic_launcher_background.png+ the<background>elementsetAdaptiveIconMonochrome(source, project, fit?)ic_launcher_monochrome.png+ the<monochrome>elementsetAdaptiveIconBackgroundColor(color, project)values/ic_launcher_background.xml+ a@color<background>clearAdaptiveIcon(project)iOS —
iosIcons:setAppIcon(source, project, backgroundColor?)AppIcon-512@2x.pnginAppIcon.appiconset, transparency flattened ontobackgroundColor(the App Store rejects an alpha channel)setLayeredAppIcon(source, project).iconbundle toApp/AppIcon.iconand registers it with the Xcode targetiOS 26 / Xcode 26 added layered app icons, authored in Icon Composer and stored as a
.iconfile package — a directory holdingicon.jsonand anAssets/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 writesicon.json.The two tiers are mutually exclusive. A target declares one
ASSETCATALOG_COMPILER_APPICON_NAME, so a.iconand an.appiconsetcannot both answer toAppIcon— setting either clears the other. actool back-deploys flattened icons from the bundle, so older iOS stays covered. Building a project with a.iconneeds Xcode 26+.Both are
set*because they fill the same slot.generateLegacyIconskeepsgenerate*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
Rationale / Problems Fixed
Adaptive icon bugs fixed
AssetKind.Icontemplates and cast them to adaptive templates, soic_launcher_foreground.pngwas written at 192px at xxxhdpi instead of the 432px the 108dp canvas needs.<inset android:inset="16.7%">, leaving the outer 18dp of the 108dp canvas transparent and compositing ontoAdaptiveIconDrawable'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.<monochrome>support, so no Android 13+ themed icons.fit: cover; resizes now passfit: contain, so the ends of a wordmark survive.Legacy icon bugs fixed
.toBuffer()calls omitted.png(), so the buffer kept the source's encoding. A JPEG has no alpha channel, so the transparent bandsfit: containcreates were flattened to black on the way out,extend({background: TRANSPARENT})had no alpha to write a transparent border into, andcomposite({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.writeLayerImagealready did this correctly; only the two legacy functions did not.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
.iconwith the Xcode projectA
.iconis a directory that Xcode treats as one file: pbxproj typefolder.iconcomposer.icon(UTIcom.apple.iconcomposer.icon), a single file reference in the Resources build phase, not expanded.IosProjectgainedaddResourceFile()/removeResourceFile()for this. Four things that are not obvious:xcode'sdetectType()knows a handful of extensions and calls everything elseunknown. A.iconlabelled 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 privatecorrectForResourcesPath(), which dereferencespbxGroupByName('Resources')with no null check. The Capacitor template has noResourcesgroup, so the library's own entry point throws aTypeErroron every Capacitor project. The public primitives it wraps are called directly instead. (shell-handlerindependently hand-rolled the same workaround for its resource copying.)omitEmptyValues, which would change how the whole project serializes — so that one object is pruned, or the pbxproj gains literalfileEncoding = undefined;lines.Descriptor handling
AdaptiveIconDescriptortreatsmipmap-anydpi-v26/ic_launcher.xmlandic_launcher_round.xmlas 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_backgroundresource 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.LayerFitLayer 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 onlydocumentElement, so a multi-element fragment lost all but its first element. It now inserts every parsed element, matchinginjectFragment()(fixed in 30ae8d4). The fragment is still parsed per match, becauseinsertBeforemoves nodes rather than copying them.Breaking Changes
Everything exported from
src/project/assets/**is removed:AssetGenerator,AssetGeneratorOptions,AndroidAssetGenerator,IosAssetGenerator,InputAsset,OutputAsset, and theAssetKind,Platform,Format,AndroidDensity,IosIdiomenums and*OutputAssetTemplateinterfaces.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?)orsetAdaptiveIconBackgroundColor(color, project)...AssetKind.Logo...(icon + adaptive layers in one call)generateLegacyIconsand the layer setters you wantnew 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?)AssetGeneratorOptionsis gone with it, soandroidFlavor,iconBackgroundColorandiconBackgroundColorDarkno longer exist; the iOS background color is now an argument tosetAppIcon. Calls return the paths written (string[]) instead ofOutputAsset[]. Android resources go togetResourcesRoot()like the rest of the Android helpers, i.e.app/src/main/res, so a non-mainproduct flavor can no longer be targeted.iosIcons.generateAppIconis renamediosIcons.setAppIcon, so that both iOS tiers read as filling the one app-icon slot they share.Other behavior changes:
fit: 'viewport'is scaled to 76/108, where it previously filled the canvas and was inset by the descriptor.setAppIconnow clears any layered icon, andsetLayeredAppIcondeletes the app icon set — the two cannot coexist under oneASSETCATALOG_COMPILER_APPICON_NAME.setAppIconalso commits, and points that build setting atAppIcon.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-templateandios-pods-template, and the OutSystemsios-templateandios-template-spm, with a.iconbundle authored in Icon Composer:xcodebuildwith Xcode 26.3 succeeds; actool receives the.iconalongsideAssets.xcassetswith--app-icon AppIcon.assetutil --infoon the builtAssets.carshowsAppIcon.iconstackforUIAppearanceLight,UIAppearanceDarkandISAppearanceTintable, the SVG layers as vectors, and the back-deployedAppIcon60x60@2x.png/AppIcon76x76@2x~ipad.pngthat actool derives from the bundle. No raw.iconships in the app.xcodebuild -liststill parses the project; no= undefined;is written.Contents.json.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
Notes / Comments
The
.iconhalf of the caveat this PR originally noted is now resolved: dark and tinted appearances are expressed by the layered bundle, sosetLayeredAppIconis the path for them rather than coexisting entries in the asset catalog.What remains, still documented in
icons/ios.ts:setAppIcondrops every other image the catalog listed and deletes the files. That is unchanged pre-existing behaviour for the flat tier.updateContentsJsonnow rebuilds a missingContents.jsonrather than rejecting, since installing a layered icon deletes the set.declarations.d.tsdeclaresxcode/lib/pbxFileso the library's own file constructor can be used directly. It is a deliberate reach into an internal path, safe becausexcode@3.0.1publishes noexportsmap — and if that ever changes it breaks at compile time rather than silently.