diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index daebcb7fb2..d35fc37d55 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -458,7 +458,11 @@ cn1_icon_[_].png * `cn1_icon_en_GB.png`: British English * `cn1_icon_es_MX.png`: Mexican Spanish -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 `_` match first, then fall back to a bare `` 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. +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 would be upscaled and look blurry, so the build now fails with a clear error when it detects an undersized localized icon rather than shipping a soft one. 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. + +NOTE: These icons live under `src/main/resources` and Maven copies them into `target/classes` incrementally — it *doesn't* 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 fails when it finds an undersized `cn1_icon_*.png` in the compiled output that's about to be bundled and sent to the build server. + +At runtime the builders look for a `_` match first, then fall back to a bare `` 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. ===== Android behavior diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index b14cf52869..0ada2863ad 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -4966,7 +4966,7 @@ public void unzip(InputStream source, File classesDir, File resDir, File sourceD * the supplied variant receive it.

*/ private void processLocalizedIcons(File assetsDir, File resDir, boolean enableAdaptiveIcons, - BufferedImage defaultIcon) throws IOException { + BufferedImage defaultIcon) throws IOException, BuildException { File[] candidates = assetsDir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { @@ -4977,6 +4977,12 @@ public boolean accept(File dir, String name) { if (candidates == null || candidates.length == 0) { return; } + // Icons smaller than the largest launcher density would have to be upscaled and would + // render blurry. Collect any such offenders and fail the build at the end so a soft icon + // never reaches production. The threshold matches the largest size writeLocalizedIconSet + // emits: 192px normally, 432px for the adaptive foreground. + int largestTarget = enableAdaptiveIcons ? 432 : 192; + List undersizedIcons = new ArrayList(); Set languagesWithRegion = new HashSet(); Set languagesWithLanguageOnly = new HashSet(); for (File candidate : candidates) { @@ -5013,12 +5019,26 @@ public boolean accept(File dir, String name) { continue; } + // Anything smaller than the largest launcher density would be upscaled and render + // blurry on high-density devices. Record it and fail the build after the loop rather + // than silently shipping a soft icon to production. + if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) { + undersizedIcons.add(name + " (" + img.getWidth() + "x" + img.getHeight() + "px)"); + } + writeLocalizedIconSet(resDir, qualifier, img, enableAdaptiveIcons); candidate.delete(); log("Registered localized launcher icon for qualifier " + qualifier + " (" + name + ")"); } + if (!undersizedIcons.isEmpty()) { + throw new BuildException("The following localized launcher icon(s) are smaller than " + + largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon: " + + undersizedIcons + ". Supply each localized icon at no less than " + largestTarget + "x" + + largestTarget + "px (1024x1024 is recommended, matching the main app icon)."); + } + for (String lang : languagesWithRegion) { if (languagesWithLanguageOnly.contains(lang)) { continue; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java index 13e9f6a621..942b4748d7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java @@ -160,6 +160,81 @@ private void mergeJars(File dest, File... src) { } + /** + * Localized launcher icons (cn1_icon_<lang>[_<country>].png) are scaled up to the + * largest launcher density by the build server, so a low-resolution source produces a + * blurry icon. Maven copies these into the build output (target/classes) with its + * incremental resource plugin, which also leaves stale copies behind when a source icon + * is removed or replaced (only {@code mvn clean} clears them). Scan the compiled output + * directories that will be bundled and sent to the build server and warn about any + * localized icon that is too small to render sharply -- this catches both an undersized + * new icon and an outdated low-resolution one lingering in target/classes. + * + * @param classpathElements the compile classpath; directory entries are the project / + * module {@code target/classes} folders that get bundled. + * @param codenameOneSettings the project's codenameone_settings.properties, used to detect + * whether adaptive icons are enabled (which raises the target size). + */ + private void warnAboutSmallLocalizedIcons(List classpathElements, File codenameOneSettings) + throws MojoFailureException { + int largestTarget = 192; + try { + Properties settings = new Properties(); + try (FileInputStream fis = new FileInputStream(codenameOneSettings)) { + settings.load(fis); + } + if ("true".equals(settings.getProperty("codename1.arg.android.enableAdaptiveIcons", "false").trim())) { + largestTarget = 432; + } + } catch (IOException ex) { + getLog().debug("Could not read " + codenameOneSettings + " to determine adaptive icon setting", ex); + } + List undersizedIcons = new ArrayList(); + for (String element : classpathElements) { + File dir = new File(element); + if (dir.isDirectory()) { + collectSmallLocalizedIcons(dir, largestTarget, undersizedIcons); + } + } + if (!undersizedIcons.isEmpty()) { + throw new MojoFailureException("The following localized launcher icon(s) are smaller than " + + largestTarget + "x" + largestTarget + "px and would be upscaled to a blurry icon in the " + + "production build:\n " + String.join("\n ", undersizedIcons) + + "\nSupply each localized icon at no less than " + largestTarget + "x" + largestTarget + + "px (1024x1024 recommended, matching the main app icon). NOTE: if you recently replaced an icon, " + + "an offending copy may be a stale resource left in target/classes -- run 'mvn clean' to clear it."); + } + } + + private void collectSmallLocalizedIcons(File dir, int largestTarget, List undersizedIcons) { + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectSmallLocalizedIcons(child, largestTarget, undersizedIcons); + continue; + } + String lower = child.getName().toLowerCase(); + if (!lower.startsWith("cn1_icon_") || !lower.endsWith(".png")) { + continue; + } + try { + BufferedImage img = ImageIO.read(child); + if (img == null) { + undersizedIcons.add(child + " (not a valid PNG image)"); + continue; + } + if (img.getWidth() < largestTarget || img.getHeight() < largestTarget) { + undersizedIcons.add(child + " (" + img.getWidth() + "x" + img.getHeight() + "px)"); + } + } catch (IOException ex) { + getLog().debug("Could not read localized icon " + child + " to check its resolution", ex); + } + } + } + /** * The dependency scopes to include in the jar file that is sent to the build server. */ @@ -323,7 +398,7 @@ private boolean isLocalBuildTarget(String buildTarget) { || BUILD_TARGET_WINDOWS_NATIVE.equals(buildTarget)); } - private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException { + private void createAntProject() throws IOException, LibraryPropertiesException, MojoExecutionException, MojoFailureException { File cn1dir = new File(project.getBuild().getDirectory() + File.separator + "codenameone"); File antProject = new File(cn1dir, "antProject"); @@ -354,6 +429,8 @@ private void createAntProject() throws IOException, LibraryPropertiesException, } + warnAboutSmallLocalizedIcons(cpElements, codenameOneSettings); + File appExtensionsJar = getAppExtensionsJar(); if (appExtensionsJar != null) { cpElements.add(appExtensionsJar.getAbsolutePath());