From 1e027d582c7d318dc2a90614da697b328db163b6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:15:12 +0300 Subject: [PATCH] Fixed localized icon generation in iOS (again) --- .../Miscellaneous-Features.asciidoc | 4 +- .../com/codename1/builders/IPhoneBuilder.java | 187 ++++++++++-------- 2 files changed, 105 insertions(+), 86 deletions(-) diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index 6f452056b6..a7fdc949b2 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -473,8 +473,8 @@ No code changes are required — Android's resource framework switches icons whe iOS does not localize launcher icons natively, so Codename One wires up https://developer.apple.com/documentation/uikit/uiapplication/2806818-setalternateiconname[alternate app icons] for you: -* For each detected locale the build generates `AppIcon__@2x.png` (120×120), `@3x.png` (180×180), `@2x~ipad.png` (152×152) and `83.5x83.5@2x~ipad.png` (167×167 for iPad Pro) in the app bundle root. -* A `CFBundleIcons` (and `CFBundleIcons~ipad`) entry is injected into `Info.plist` containing a `CFBundleAlternateIcons` dictionary with one entry per locale. `CFBundlePrimaryIcon` continues to reference the default `iPhone7App`/`iPadApp7` image families. +* For each detected locale the build generates an `AppIcon__.appiconset` inside `Images.xcassets` containing the 120×120, 180×180, 152×152 and 167×167 (iPad Pro) PNGs along with a matching `Contents.json`. +* The build adds the matching `ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES` Xcode build setting so `actool` emits a coherent partial `Info.plist` covering both `CFBundlePrimaryIcon` and `CFBundleAlternateIcons`. The user's `Info.plist` is left untouched; injecting `CFBundleIcons` manually would be dropped during the actool merge and is therefore avoided. * The `CodenameOne_GLAppDelegate` is patched to call `-[UIApplication setAlternateIconName:completionHandler:]` at launch. The delegate reads `[NSLocale preferredLanguages]`, tries the full `_` key first, then falls back to the language-only key, and clears the alternate icon (reverting to the default) if no variant matches. * The injection is idempotent and runs before the `ios.afterFinishLaunching` hook, so any custom code you supply via that build hint is unaffected. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 6a508d11bc..609536dee9 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -2264,6 +2264,8 @@ public void usesClassMethod(String cls, String method) { injectToPlist(tmpFile, resDir, request); + addLocalizedIconsBuildSetting(pbxprojFile); + } catch (Exception ex) { throw new BuildException("Failed to inject into plist"); } @@ -2797,9 +2799,13 @@ public boolean accept(File file, String string) { if(inject.indexOf("CFBundleShortVersionString") < 0) { inject += "\nCFBundleShortVersionString " + buildVersion +""; } - if (!localizedIcons.isEmpty() && !inject.contains("CFBundleAlternateIcons")) { - inject += buildLocalizedIconsPlistFragment(); - } + // Localized icons are emitted by actool through the asset catalog + // (Images.xcassets/AppIcon_.appiconset) and the + // ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES build setting; the + // resulting partial Info.plist already contains the correct + // CFBundleIcons entries, so we deliberately do not inject any + // CFBundleAlternateIcons fragment here. Doing so would conflict with + // actool's output during the Info.plist merge. String locationUsageDescription = null; if (xcodeVersion >= 9) { if ( (locationUsageDescription = request.getArg("ios.locationUsageDescription", null)) != null ){ @@ -3158,72 +3164,6 @@ private void copyIcon(String name, File srcDir, File destDir) throws IOException copy(new File(srcDir, name), new File(destDir, name)); } - private String buildLocalizedIconsPlistFragment() { - // iOS resolves alternate icons by appending @2x/@3x/~ipad to each basename - // listed in CFBundleIconFiles. The iPhone basename catches AppIcon_@2x.png - // (120) and AppIcon_@3x.png (180); the iPad basename catches - // AppIcon_@2x~ipad.png (152). The 167x167 iPad Pro size cannot be - // expressed via @-modifiers, so it is referenced through its explicit - // 83.5x83.5 basename. - StringBuilder iphoneAlternate = new StringBuilder(); - StringBuilder ipadAlternate = new StringBuilder(); - for (Map.Entry entry : localizedIcons.entrySet()) { - String iconName = entry.getKey(); - iphoneAlternate.append(" ").append(iconName).append("\n"); - iphoneAlternate.append(" \n"); - iphoneAlternate.append(" CFBundleIconFiles\n"); - iphoneAlternate.append(" \n"); - iphoneAlternate.append(" ").append(iconName).append("\n"); - iphoneAlternate.append(" \n"); - iphoneAlternate.append(" UIPrerenderedIcon\n"); - iphoneAlternate.append(" \n"); - iphoneAlternate.append(" \n"); - - ipadAlternate.append(" ").append(iconName).append("\n"); - ipadAlternate.append(" \n"); - ipadAlternate.append(" CFBundleIconFiles\n"); - ipadAlternate.append(" \n"); - ipadAlternate.append(" ").append(iconName).append("\n"); - ipadAlternate.append(" ").append(iconName).append("83.5x83.5\n"); - ipadAlternate.append(" \n"); - ipadAlternate.append(" UIPrerenderedIcon\n"); - ipadAlternate.append(" \n"); - ipadAlternate.append(" \n"); - } - StringBuilder out = new StringBuilder(); - out.append("\nCFBundleIcons\n"); - out.append("\n"); - out.append(" CFBundlePrimaryIcon\n"); - out.append(" \n"); - out.append(" CFBundleIconFiles\n"); - out.append(" \n"); - out.append(" iPhone7App\n"); - out.append(" iPhoneApp\n"); - out.append(" \n"); - out.append(" \n"); - out.append(" CFBundleAlternateIcons\n"); - out.append(" \n"); - out.append(iphoneAlternate); - out.append(" \n"); - out.append("\n"); - out.append("CFBundleIcons~ipad\n"); - out.append("\n"); - out.append(" CFBundlePrimaryIcon\n"); - out.append(" \n"); - out.append(" CFBundleIconFiles\n"); - out.append(" \n"); - out.append(" iPadApp7\n"); - out.append(" iPhone7App\n"); - out.append(" \n"); - out.append(" \n"); - out.append(" CFBundleAlternateIcons\n"); - out.append(" \n"); - out.append(ipadAlternate); - out.append(" \n"); - out.append("\n"); - return out.toString(); - } - private String buildLocalizedIconSelectorObjC() { StringBuilder mapping = new StringBuilder(); mapping.append(" @{ "); @@ -3337,19 +3277,24 @@ private boolean generateIcons(BuildRequest request) throws Exception { "iPadPro@2x.png", "AppStore.png"); - processLocalizedIcons(resDir); + processLocalizedIcons(resDir, request); return true; } /** * Scans the resources directory for files named cn1_icon_LANG[_COUNTRY].png - * and registers them as iOS alternate app icons so the bundle will contain - * per-locale launcher icons in addition to the default icon. Runtime icon - * selection is wired up in the GL app delegate and the Info.plist - * CFBundleIcons entry is populated in {@link #injectToPlist}. + * and registers them as iOS alternate app icons. Each detected locale is + * written as its own AppIcon_LOC.appiconset inside Images.xcassets so that + * actool produces a coherent CFBundleIcons / CFBundleAlternateIcons + * partial Info.plist; mixing manual CFBundleIcons entries in the user + * Info.plist with an asset-catalog-managed primary icon is unsafe because + * actool's partial plist replaces ours during the merge. The matching + * ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES build setting is added in + * {@link #addLocalizedIconsBuildSetting}; runtime icon selection is wired + * up in the GL app delegate. */ - private void processLocalizedIcons(File resDir) throws IOException { + private void processLocalizedIcons(File resDir, BuildRequest request) throws IOException { File[] candidates = resDir.listFiles(new FilenameFilter() { @Override public boolean accept(File dir, String name) { @@ -3360,6 +3305,8 @@ public boolean accept(File dir, String name) { if (candidates == null || candidates.length == 0) { return; } + File assetCatalogDir = new File(tmpFile, "dist/" + request.getMainClass() + + "-src/Images.xcassets"); for (File candidate : candidates) { String base = candidate.getName(); // strip prefix/suffix @@ -3388,15 +3335,24 @@ public boolean accept(File dir, String name) { candidate.delete(); continue; } - // File names must match the basenames listed in CFBundleIconFiles so iOS - // can resolve them via the standard @2x/@3x/~ipad modifiers; the - // 83.5x83.5 iPad Pro icon cannot be expressed via modifiers and so - // carries the size in the file name and is referenced explicitly in - // buildLocalizedIconsPlistFragment. - createIconFile(new File(resDir, iconName + "@2x.png"), img, 120, 120); - createIconFile(new File(resDir, iconName + "@3x.png"), img, 180, 180); - createIconFile(new File(resDir, iconName + "@2x~ipad.png"), img, 152, 152); - createIconFile(new File(resDir, iconName + "83.5x83.5@2x~ipad.png"), img, 167, 167); + + File alternateIconset = new File(assetCatalogDir, iconName + ".appiconset"); + if (!alternateIconset.exists() && !alternateIconset.mkdirs()) { + log("Failed to create alternate icon set directory: " + + alternateIconset.getAbsolutePath()); + candidate.delete(); + continue; + } + String iphone2x = iconName + "60x60@2x.png"; + String iphone3x = iconName + "60x60@3x.png"; + String ipad2x = iconName + "76x76@2x~ipad.png"; + String ipadPro2x = iconName + "83.5x83.5@2x~ipad.png"; + createIconFile(new File(alternateIconset, iphone2x), img, 120, 120); + createIconFile(new File(alternateIconset, iphone3x), img, 180, 180); + createIconFile(new File(alternateIconset, ipad2x), img, 152, 152); + createIconFile(new File(alternateIconset, ipadPro2x), img, 167, 167); + writeAlternateAppIconContentsJson(new File(alternateIconset, "Contents.json"), + iphone2x, iphone3x, ipad2x, ipadPro2x); localizedIcons.put(iconName, localeKey); // Remove the original so it isn't bundled as a stray resource. candidate.delete(); @@ -3404,6 +3360,69 @@ public boolean accept(File dir, String name) { } } + private void writeAlternateAppIconContentsJson(File contentsJson, + String iphone2x, String iphone3x, String ipad2x, String ipadPro2x) throws IOException { + String json = "{\n" + + " \"images\" : [\n" + + " {\n" + + " \"size\" : \"60x60\",\n" + + " \"idiom\" : \"iphone\",\n" + + " \"filename\" : \"" + iphone2x + "\",\n" + + " \"scale\" : \"2x\"\n" + + " },\n" + + " {\n" + + " \"size\" : \"60x60\",\n" + + " \"idiom\" : \"iphone\",\n" + + " \"filename\" : \"" + iphone3x + "\",\n" + + " \"scale\" : \"3x\"\n" + + " },\n" + + " {\n" + + " \"size\" : \"76x76\",\n" + + " \"idiom\" : \"ipad\",\n" + + " \"filename\" : \"" + ipad2x + "\",\n" + + " \"scale\" : \"2x\"\n" + + " },\n" + + " {\n" + + " \"size\" : \"83.5x83.5\",\n" + + " \"idiom\" : \"ipad\",\n" + + " \"filename\" : \"" + ipadPro2x + "\",\n" + + " \"scale\" : \"2x\"\n" + + " }\n" + + " ],\n" + + " \"info\" : {\n" + + " \"version\" : 1,\n" + + " \"author\" : \"xcode\"\n" + + " }\n" + + "}\n"; + try (Writer w = new OutputStreamWriter(Files.newOutputStream(contentsJson.toPath()), + StandardCharsets.UTF_8)) { + w.write(json); + } + } + + private void addLocalizedIconsBuildSetting(File pbx) throws IOException { + if (localizedIcons.isEmpty()) { + return; + } + StringBuilder names = new StringBuilder(); + boolean first = true; + for (String iconName : localizedIcons.keySet()) { + if (!first) { + names.append(' '); + } + first = false; + names.append(iconName); + } + // actool reads ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES and emits a + // partial Info.plist with both CFBundlePrimaryIcon and CFBundleAlternateIcons, + // which is what we need for setAlternateIconName: to resolve at runtime. + replaceAllInFile(pbx, + "ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;", + "ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n" + + "\t\t\t\tASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = \"" + + names + "\";"); + } + private boolean generateLaunchScreen(BuildRequest request) throws Exception { File buildinRes = getBuildinRes();