diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index 65310c9881..afef866c87 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -469,6 +469,8 @@ On Android the build generates locale-qualified drawable resources at every dens No code changes are required—Android's resource framework switches icons when the locale changes. +When you supply a region-qualified icon (such as `cn1_icon_ar_AE.png`) without a matching language-only variant, the build also emits the *default* (non-localized) icon into `drawable-/` and the matching `mipmap-*-/` directories. This barrier is required because Android's resource resolver (API 24+) walks every child of the parent locale when it can't find an exact or parent-language match, and would otherwise pick `ar-rAE` for, say, an `ar-PK` device. The barrier short-circuits that lookup so only devices whose region matches the supplied variant receive the localized icon. If you also ship a language-only file (for example `cn1_icon_ar.png`) it is used as the barrier instead, so you keep full control of the fallback icon for Arabic speakers outside AE. + ===== iOS behaviour iOS doesn't localize launcher icons natively, so Codename One wires up https://developer.apple.com/documentation/uikit/uiapplication/2806818-setalternateiconname[alternate app icons] for you: 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 50f4fe80fb..9d90635a80 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 @@ -53,11 +53,13 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Scanner; +import java.util.Set; import java.util.StringTokenizer; import java.util.zip.ZipEntry; @@ -1865,7 +1867,7 @@ public void usesClassMethod(String cls, String method) { createIconFile(new File(drawableDir, "ic_stat_notify.png"), iconImage, 24, 24); } - processLocalizedIcons(assetsDir, resDir, enableAdaptiveIcons); + processLocalizedIcons(assetsDir, resDir, enableAdaptiveIcons, iconImage); } catch (IOException ex) { throw new BuildException("Failed to generate icon files", ex); } @@ -4467,8 +4469,20 @@ public void unzip(InputStream source, File classesDir, File resDir, File sourceD * picks up the correct launcher icon at runtime based on the device * locale. Source files are removed from assetsDir so they are not shipped * as stray assets. + * + *

When a region-qualified icon (e.g. cn1_icon_ar_AE.png) is supplied + * without a matching language-only counterpart (cn1_icon_ar.png), the + * default app icon is also written to drawable-<lang>/ (and the + * matching mipmap-*-<lang>/ directories). This is required to + * suppress Android's sibling-locale fallback: starting with API 24 the + * resource resolver, when it can't find an exact (ar-rPK) or parent (ar) + * match, walks every child of the parent locale and would otherwise pick + * ar-rAE for an ar-PK user. Inserting the default icon at the parent + * level short-circuits that lookup so only devices whose region matches + * the supplied variant receive it.

*/ - private void processLocalizedIcons(File assetsDir, File resDir, boolean enableAdaptiveIcons) throws IOException { + private void processLocalizedIcons(File assetsDir, File resDir, boolean enableAdaptiveIcons, + BufferedImage defaultIcon) throws IOException { File[] candidates = assetsDir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { @@ -4479,6 +4493,22 @@ public boolean accept(File dir, String name) { if (candidates == null || candidates.length == 0) { return; } + Set languagesWithRegion = new HashSet(); + Set languagesWithLanguageOnly = new HashSet(); + for (File candidate : candidates) { + String name = candidate.getName(); + String core = name.substring("cn1_icon_".length(), name.length() - ".png".length()); + String[] parts = core.split("_"); + if (parts.length < 1 || parts[0].length() != 2) { + continue; + } + String lang = parts[0].toLowerCase(); + if (parts.length >= 2 && parts[1].length() == 2) { + languagesWithRegion.add(lang); + } else { + languagesWithLanguageOnly.add(lang); + } + } for (File candidate : candidates) { String name = candidate.getName(); String core = name.substring("cn1_icon_".length(), name.length() - ".png".length()); @@ -4499,30 +4529,45 @@ public boolean accept(File dir, String name) { continue; } - createIconFile(makeLocalizedDir(resDir, "drawable", qualifier, "icon.png"), img, 128, 128); - createIconFile(makeLocalizedDir(resDir, "drawable-hdpi", qualifier, "icon.png"), img, 72, 72); - createIconFile(makeLocalizedDir(resDir, "drawable-ldpi", qualifier, "icon.png"), img, 36, 36); - createIconFile(makeLocalizedDir(resDir, "drawable-mdpi", qualifier, "icon.png"), img, 48, 48); - createIconFile(makeLocalizedDir(resDir, "drawable-xhdpi", qualifier, "icon.png"), img, 96, 96); - createIconFile(makeLocalizedDir(resDir, "drawable-xxhdpi", qualifier, "icon.png"), img, 144, 144); - createIconFile(makeLocalizedDir(resDir, "drawable-xxxhdpi", qualifier, "icon.png"), img, 192, 192); + writeLocalizedIconSet(resDir, qualifier, img, enableAdaptiveIcons); - if (enableAdaptiveIcons) { - createIconFile(makeLocalizedDir(resDir, "mipmap-mdpi", qualifier, "ic_launcher.png"), img, 48, 48); - createIconFile(makeLocalizedDir(resDir, "mipmap-hdpi", qualifier, "ic_launcher.png"), img, 72, 72); - createIconFile(makeLocalizedDir(resDir, "mipmap-xhdpi", qualifier, "ic_launcher.png"), img, 96, 96); - createIconFile(makeLocalizedDir(resDir, "mipmap-xxhdpi", qualifier, "ic_launcher.png"), img, 144, 144); - createIconFile(makeLocalizedDir(resDir, "mipmap-xxxhdpi", qualifier, "ic_launcher.png"), img, 192, 192); + candidate.delete(); + log("Registered localized launcher icon for qualifier " + qualifier + " (" + name + ")"); + } - createIconFile(makeLocalizedDir(resDir, "mipmap-mdpi", qualifier, "ic_launcher_foreground.png"), img, 108, 108); - createIconFile(makeLocalizedDir(resDir, "mipmap-hdpi", qualifier, "ic_launcher_foreground.png"), img, 162, 162); - createIconFile(makeLocalizedDir(resDir, "mipmap-xhdpi", qualifier, "ic_launcher_foreground.png"), img, 216, 216); - createIconFile(makeLocalizedDir(resDir, "mipmap-xxhdpi", qualifier, "ic_launcher_foreground.png"), img, 324, 324); - createIconFile(makeLocalizedDir(resDir, "mipmap-xxxhdpi", qualifier, "ic_launcher_foreground.png"), img, 432, 432); + for (String lang : languagesWithRegion) { + if (languagesWithLanguageOnly.contains(lang)) { + continue; } + String qualifier = "-" + lang; + writeLocalizedIconSet(resDir, qualifier, defaultIcon, enableAdaptiveIcons); + log("Registered default-icon barrier for qualifier " + qualifier + + " to suppress Android sibling-locale fallback"); + } + } - candidate.delete(); - log("Registered localized launcher icon for qualifier " + qualifier + " (" + name + ")"); + private void writeLocalizedIconSet(File resDir, String qualifier, BufferedImage img, + boolean enableAdaptiveIcons) throws IOException { + createIconFile(makeLocalizedDir(resDir, "drawable", qualifier, "icon.png"), img, 128, 128); + createIconFile(makeLocalizedDir(resDir, "drawable-hdpi", qualifier, "icon.png"), img, 72, 72); + createIconFile(makeLocalizedDir(resDir, "drawable-ldpi", qualifier, "icon.png"), img, 36, 36); + createIconFile(makeLocalizedDir(resDir, "drawable-mdpi", qualifier, "icon.png"), img, 48, 48); + createIconFile(makeLocalizedDir(resDir, "drawable-xhdpi", qualifier, "icon.png"), img, 96, 96); + createIconFile(makeLocalizedDir(resDir, "drawable-xxhdpi", qualifier, "icon.png"), img, 144, 144); + createIconFile(makeLocalizedDir(resDir, "drawable-xxxhdpi", qualifier, "icon.png"), img, 192, 192); + + if (enableAdaptiveIcons) { + createIconFile(makeLocalizedDir(resDir, "mipmap-mdpi", qualifier, "ic_launcher.png"), img, 48, 48); + createIconFile(makeLocalizedDir(resDir, "mipmap-hdpi", qualifier, "ic_launcher.png"), img, 72, 72); + createIconFile(makeLocalizedDir(resDir, "mipmap-xhdpi", qualifier, "ic_launcher.png"), img, 96, 96); + createIconFile(makeLocalizedDir(resDir, "mipmap-xxhdpi", qualifier, "ic_launcher.png"), img, 144, 144); + createIconFile(makeLocalizedDir(resDir, "mipmap-xxxhdpi", qualifier, "ic_launcher.png"), img, 192, 192); + + createIconFile(makeLocalizedDir(resDir, "mipmap-mdpi", qualifier, "ic_launcher_foreground.png"), img, 108, 108); + createIconFile(makeLocalizedDir(resDir, "mipmap-hdpi", qualifier, "ic_launcher_foreground.png"), img, 162, 162); + createIconFile(makeLocalizedDir(resDir, "mipmap-xhdpi", qualifier, "ic_launcher_foreground.png"), img, 216, 216); + createIconFile(makeLocalizedDir(resDir, "mipmap-xxhdpi", qualifier, "ic_launcher_foreground.png"), img, 324, 324); + createIconFile(makeLocalizedDir(resDir, "mipmap-xxxhdpi", qualifier, "ic_launcher_foreground.png"), img, 432, 432); } }