Skip to content

Commit b9053bf

Browse files
shai-almogclaude
andcommitted
Fail the build on undersized localized launcher icons
Localized launcher icons (cn1_icon_<lang>[_<country>].png) are scaled up to the largest launcher density at build time, so a low-resolution source produces a blurry icon. Two related problems were reported on Android: 1. A small localized icon was upscaled and shipped blurry with no warning. 2. Maven copies these resources into target/classes incrementally and does not delete stale copies when a source icon is removed/renamed, so an old low-res icon kept getting bundled until 'mvn clean' was run manually. This adds a hard build failure (not just a warning) when a localized icon is smaller than the size it would be upscaled to (192px normally, 432px for the adaptive foreground), so a soft icon can no longer reach production: - AndroidGradleBuilder.processLocalizedIcons: collects undersized icons and throws a BuildException with a clear message before producing the APK. - CN1BuildMojo: scans the compiled output (target/classes) that is about to be bundled and sent to the build server and fails with a MojoFailureException, pointing at stale copies and recommending 'mvn clean'. Docs updated with the resolution recommendation and the stale-resource note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 210c8c3 commit b9053bf

3 files changed

Lines changed: 104 additions & 3 deletions

File tree

docs/developer-guide/Miscellaneous-Features.asciidoc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,11 @@ cn1_icon_<lang>[_<country>].png
458458
* `cn1_icon_en_GB.png`: British English
459459
* `cn1_icon_es_MX.png`: Mexican Spanish
460460

461-
Supply a square source image at least 432×432 pixels (the largest size emitted for Android adaptive icons); the build resizes it to every target density. The default app icon continues to be controlled by your `codenameone_settings.properties` file and is used whenever the device locale doesn't match any of the localized variants. At runtime the builders look for a `<lang>_<COUNTRY>` match first, then fall back to a bare `<lang>` match. Providing both (for example `cn1_icon_en.png` plus `cn1_icon_en_GB.png`) lets you give British users a country-specific icon while every other English locale still receives the generic English icon.
461+
Supply a square source image at least 432×432 pixels (the largest size emitted for Android adaptive icons; 1024×1024 is recommended, matching the main app icon); the build resizes it to every target density. A smaller source is upscaled and will look blurry — the build now logs a warning when it detects an undersized localized icon. The default app icon continues to be controlled by your `codenameone_settings.properties` file and is used whenever the device locale doesn't match any of the localized variants.
462+
463+
NOTE: These icons live under `src/main/resources` and Maven copies them into `target/classes` incrementally — it does *not* delete the copy when you remove or rename the source file. If you replace a localized icon and still see the old (often blurry) one, run `mvn clean` to clear the stale resource from `target/classes` before rebuilding. The build also warns when it finds an undersized `cn1_icon_*.png` in the compiled output that is about to be bundled and sent to the build server.
464+
465+
At runtime the builders look for a `<lang>_<COUNTRY>` match first, then fall back to a bare `<lang>` match. Providing both (for example `cn1_icon_en.png` plus `cn1_icon_en_GB.png`) lets you give British users a country-specific icon while every other English locale still receives the generic English icon.
462466

463467
===== Android behavior
464468

maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4966,7 +4966,7 @@ public void unzip(InputStream source, File classesDir, File resDir, File sourceD
49664966
* the supplied variant receive it.</p>
49674967
*/
49684968
private void processLocalizedIcons(File assetsDir, File resDir, boolean enableAdaptiveIcons,
4969-
BufferedImage defaultIcon) throws IOException {
4969+
BufferedImage defaultIcon) throws IOException, BuildException {
49704970
File[] candidates = assetsDir.listFiles(new FilenameFilter() {
49714971
@Override
49724972
public boolean accept(File dir, String name) {
@@ -4977,6 +4977,12 @@ public boolean accept(File dir, String name) {
49774977
if (candidates == null || candidates.length == 0) {
49784978
return;
49794979
}
4980+
// Icons smaller than the largest launcher density would have to be upscaled and would
4981+
// render blurry. Collect any such offenders and fail the build at the end so a soft icon
4982+
// never reaches production. The threshold matches the largest size writeLocalizedIconSet
4983+
// emits: 192px normally, 432px for the adaptive foreground.
4984+
int largestTarget = enableAdaptiveIcons ? 432 : 192;
4985+
List<String> undersizedIcons = new ArrayList<String>();
49804986
Set<String> languagesWithRegion = new HashSet<String>();
49814987
Set<String> languagesWithLanguageOnly = new HashSet<String>();
49824988
for (File candidate : candidates) {
@@ -5013,12 +5019,26 @@ public boolean accept(File dir, String name) {
50135019
continue;
50145020
}
50155021

5022+
// Anything smaller than the largest launcher density would be upscaled and render
5023+
// blurry on high-density devices. Record it and fail the build after the loop rather
5024+
// than silently shipping a soft icon to production.
5025+
if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) {
5026+
undersizedIcons.add(name + " (" + img.getWidth() + "x" + img.getHeight() + "px)");
5027+
}
5028+
50165029
writeLocalizedIconSet(resDir, qualifier, img, enableAdaptiveIcons);
50175030

50185031
candidate.delete();
50195032
log("Registered localized launcher icon for qualifier " + qualifier + " (" + name + ")");
50205033
}
50215034

5035+
if (!undersizedIcons.isEmpty()) {
5036+
throw new BuildException("The following localized launcher icon(s) are smaller than "
5037+
+ largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon: "
5038+
+ undersizedIcons + ". Supply each localized icon at no less than " + largestTarget + "x"
5039+
+ largestTarget + "px (1024x1024 is recommended, matching the main app icon).");
5040+
}
5041+
50225042
for (String lang : languagesWithRegion) {
50235043
if (languagesWithLanguageOnly.contains(lang)) {
50245044
continue;

maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,81 @@ private void mergeJars(File dest, File... src) {
160160
}
161161

162162

163+
/**
164+
* Localized launcher icons (cn1_icon_&lt;lang&gt;[_&lt;country&gt;].png) are scaled up to the
165+
* largest launcher density by the build server, so a low-resolution source produces a
166+
* blurry icon. Maven copies these into the build output (target/classes) with its
167+
* incremental resource plugin, which also leaves stale copies behind when a source icon
168+
* is removed or replaced (only {@code mvn clean} clears them). Scan the compiled output
169+
* directories that will be bundled and sent to the build server and warn about any
170+
* localized icon that is too small to render sharply -- this catches both an undersized
171+
* new icon and an outdated low-resolution one lingering in target/classes.
172+
*
173+
* @param classpathElements the compile classpath; directory entries are the project /
174+
* module {@code target/classes} folders that get bundled.
175+
* @param codenameOneSettings the project's codenameone_settings.properties, used to detect
176+
* whether adaptive icons are enabled (which raises the target size).
177+
*/
178+
private void warnAboutSmallLocalizedIcons(List<String> classpathElements, File codenameOneSettings)
179+
throws MojoFailureException {
180+
int largestTarget = 192;
181+
try {
182+
Properties settings = new Properties();
183+
try (FileInputStream fis = new FileInputStream(codenameOneSettings)) {
184+
settings.load(fis);
185+
}
186+
if ("true".equals(settings.getProperty("codename1.arg.android.enableAdaptiveIcons", "false").trim())) {
187+
largestTarget = 432;
188+
}
189+
} catch (IOException ex) {
190+
getLog().debug("Could not read " + codenameOneSettings + " to determine adaptive icon setting", ex);
191+
}
192+
List<String> undersizedIcons = new ArrayList<String>();
193+
for (String element : classpathElements) {
194+
File dir = new File(element);
195+
if (dir.isDirectory()) {
196+
collectSmallLocalizedIcons(dir, largestTarget, undersizedIcons);
197+
}
198+
}
199+
if (!undersizedIcons.isEmpty()) {
200+
throw new MojoFailureException("The following localized launcher icon(s) are smaller than "
201+
+ largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon in the "
202+
+ "production build:\n " + String.join("\n ", undersizedIcons)
203+
+ "\nSupply each localized icon at no less than " + largestTarget + "x" + largestTarget
204+
+ "px (1024x1024 recommended, matching the main app icon). NOTE: if you recently replaced an icon, "
205+
+ "an offending copy may be a stale resource left in target/classes -- run 'mvn clean' to clear it.");
206+
}
207+
}
208+
209+
private void collectSmallLocalizedIcons(File dir, int largestTarget, List<String> undersizedIcons) {
210+
File[] children = dir.listFiles();
211+
if (children == null) {
212+
return;
213+
}
214+
for (File child : children) {
215+
if (child.isDirectory()) {
216+
collectSmallLocalizedIcons(child, largestTarget, undersizedIcons);
217+
continue;
218+
}
219+
String lower = child.getName().toLowerCase();
220+
if (!lower.startsWith("cn1_icon_") || !lower.endsWith(".png")) {
221+
continue;
222+
}
223+
try {
224+
BufferedImage img = ImageIO.read(child);
225+
if (img == null) {
226+
undersizedIcons.add(child + " (not a valid PNG image)");
227+
continue;
228+
}
229+
if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) {
230+
undersizedIcons.add(child + " (" + img.getWidth() + "x" + img.getHeight() + "px)");
231+
}
232+
} catch (IOException ex) {
233+
getLog().debug("Could not read localized icon " + child + " to check its resolution", ex);
234+
}
235+
}
236+
}
237+
163238
/**
164239
* The dependency scopes to include in the jar file that is sent to the build server.
165240
*/
@@ -323,7 +398,7 @@ private boolean isLocalBuildTarget(String buildTarget) {
323398
|| BUILD_TARGET_WINDOWS_NATIVE.equals(buildTarget));
324399
}
325400

326-
private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException {
401+
private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException, MojoFailureException {
327402
File cn1dir = new File(project.getBuild().getDirectory() + File.separator + "codenameone");
328403
File antProject = new File(cn1dir, "antProject");
329404

@@ -354,6 +429,8 @@ private void createAntProject() throws IOException, LibraryPropertiesException,
354429

355430
}
356431

432+
warnAboutSmallLocalizedIcons(cpElements, codenameOneSettings);
433+
357434
File appExtensionsJar = getAppExtensionsJar();
358435
if (appExtensionsJar != null) {
359436
cpElements.add(appExtensionsJar.getAbsolutePath());

0 commit comments

Comments
 (0)