diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 410b23c698..c1104d8b08 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -929,7 +929,11 @@ InputStream getUi(String id) { /// /// Hashtable containing key value pairs for localized data public Hashtable getL10N(String id, String locale) { - return (Hashtable) ((Hashtable) resources.get(id)).get(locale); + Hashtable bundles = (Hashtable) resources.get(id); + if (bundles == null) { + return null; + } + return (Hashtable) bundles.get(locale); } /// Returns an enumration of the locales supported by this resource id @@ -942,7 +946,11 @@ public Hashtable getL10N(String id, String locale) { /// /// enumeration of strings containing bundle names public Enumeration listL10NLocales(String id) { - return ((Hashtable) resources.get(id)).keys(); + Hashtable bundles = (Hashtable) resources.get(id); + if (bundles == null) { + return null; + } + return bundles.keys(); } /// Returns a collection of the l10 locale names @@ -955,7 +963,11 @@ public Enumeration listL10NLocales(String id) { /// /// collection of strings containing bundle names public Collection l10NLocaleSet(String id) { - return ((Hashtable) resources.get(id)).keySet(); + Hashtable bundles = (Hashtable) resources.get(id); + if (bundles == null) { + return null; + } + return bundles.keySet(); } /// Returns the font resource from the file diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java index 6141000822..b54fdb6508 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java @@ -272,6 +272,22 @@ public void run() { File designerJar = new File(cn1Home, "designer_1.jar"); if (System.getProperty("codename1.designer.jar", null) != null) { designerJar = new File(System.getProperty("codename1.designer.jar", null)); + } else { + // The Maven plugin declares codenameone-designer as a plugin dependency, so any + // CN1 mojo invocation has already pulled the version-pinned designer jar into m2. + // Prefer that over ~/.codenameone/designer_1.jar (which is managed by UpdateCodenameOne + // and routinely lags behind the plugin version, producing confusing CSS failures). + File m2Designer = com.codename1.impl.javase.util.MavenUtils.findDesignerJarInM2(); + if (m2Designer != null) { + designerJar = m2Designer; + } else if (designerJar.exists()) { + System.err.println("[CSSWatcher] Warning: codename1.designer.jar system property is not set " + + "and no version-pinned designer was found in the local Maven repository; " + + "falling back to " + designerJar.getAbsolutePath() + ". This file is " + + "managed by UpdateCodenameOne and may be older than the CN1 plugin in use. " + + "If CSS compilation fails, launch the simulator via the Maven cn1:run goal " + + "(which both fetches the right designer into m2 and pins it via -Dcodename1.designer.jar)."); + } } String cefDir = System.getProperty("cef.dir", cn1Home + File.separator + "cef"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java index 78fad4e539..cc01629552 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ComponentTreeInspector.java @@ -580,18 +580,32 @@ private void findScrollableContainers(Container cnt, List response) { } private void editStyle() { - File cn1dir = new File(System.getProperty("user.home"), ".codenameone"); - if(!cn1dir.exists()) { - JOptionPane.showMessageDialog(this, "Please open the designer once by opening the theme.res file", "Error Opening Designer", JOptionPane.ERROR_MESSAGE); - return; + // Prefer the version-pinned designer jar that the Maven plugin pulled into m2. + // Fallback to the legacy ~/.codenameone/designer_*.jar files (managed by UpdateCodenameOne). + File resourceEditor = null; + if (System.getProperty("codename1.designer.jar", null) != null) { + resourceEditor = new File(System.getProperty("codename1.designer.jar")); } - File resourceEditor = new File(cn1dir, "designer_1.jar"); - if(!resourceEditor.exists()) { - resourceEditor = new File(cn1dir, "designer.jar"); + if (resourceEditor == null || !resourceEditor.exists()) { + File m2Designer = com.codename1.impl.javase.util.MavenUtils.findDesignerJarInM2(); + if (m2Designer != null) { + resourceEditor = m2Designer; + } } - if(!resourceEditor.exists()) { - JOptionPane.showMessageDialog(this, "Please open the designer once by opening the theme.res file", "Error Opening Designer", JOptionPane.ERROR_MESSAGE); - return; + if (resourceEditor == null || !resourceEditor.exists()) { + File cn1dir = new File(System.getProperty("user.home"), ".codenameone"); + if(!cn1dir.exists()) { + JOptionPane.showMessageDialog(this, "Please open the designer once by opening the theme.res file", "Error Opening Designer", JOptionPane.ERROR_MESSAGE); + return; + } + resourceEditor = new File(cn1dir, "designer_1.jar"); + if(!resourceEditor.exists()) { + resourceEditor = new File(cn1dir, "designer.jar"); + } + if(!resourceEditor.exists()) { + JOptionPane.showMessageDialog(this, "Please open the designer once by opening the theme.res file", "Error Opening Designer", JOptionPane.ERROR_MESSAGE); + return; + } } File javaBin = new File(System.getProperty("java.home") + File.separator + "bin" + File.separator + "java.exe"); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 404e329d76..72320f31e7 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -14295,6 +14295,15 @@ public synchronized String get(Object key) { if (key instanceof String) { String strKey = (String) key; if (value == null) { + // Don't auto-fabricate values for meta-keys like @rtl, @im, @im-. + // These are configuration entries that callers (e.g. UIManager.setBundle) + // distinguish from "missing" by checking for null. If we echo the key back + // as the value, setBundle will treat "@im" as a real input-mode descriptor, + // tokenize it, and crash inside parseTextFieldInputMode when the resulting + // token has no '='. + if (strKey.startsWith("@")) { + return null; + } String autoValue = strKey; putInternal(strKey, autoValue); storeEntry(strKey, autoValue, true); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java b/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java index 24a1c6456e..d807cca2f5 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java @@ -6,7 +6,9 @@ package com.codename1.impl.javase.util; import com.codename1.io.Log; +import com.codename1.ui.Display; import java.io.File; +import java.net.URL; /** * @@ -63,6 +65,49 @@ public static File findJavac() { return null; } + /** + * Locate the codenameone-designer:jar-with-dependencies jar inside the local + * Maven (~/.m2) repository, using the version of the codenameone-core jar that + * is currently loaded into this JVM. Returns null if the running framework is + * not loaded from m2 (e.g. running from a build directory) or if the matching + * designer jar has not been resolved yet. + * + *

The Maven plugin declares codenameone-designer as a plugin dependency, so + * any plugin invocation (cn1:run, mvn compile when bound to the css goal, etc.) + * implicitly fetches the matching designer jar into m2. This lookup lets the + * simulator runtime use that exact version even when codename1.designer.jar + * isn't passed as a system property -- avoiding a stale ~/.codenameone/designer_1.jar + * fallback. + */ + public static File findDesignerJarInM2() { + try { + URL location = Display.class.getProtectionDomain().getCodeSource().getLocation(); + if (location == null) { + return null; + } + File coreJar = new File(location.toURI()); + // Expected layout: /com/codenameone/codenameone-core//codenameone-core-.jar + File versionDir = coreJar.getParentFile(); + if (versionDir == null) return null; + File coreDir = versionDir.getParentFile(); + if (coreDir == null) return null; + File codenameoneGroupDir = coreDir.getParentFile(); + if (codenameoneGroupDir == null) return null; + if (!"codenameone-core".equals(coreDir.getName())) { + return null; + } + String version = versionDir.getName(); + File designerVersionDir = new File(codenameoneGroupDir, "codenameone-designer" + File.separator + version); + File designer = new File(designerVersionDir, "codenameone-designer-" + version + "-jar-with-dependencies.jar"); + if (designer.isFile()) { + return designer; + } + } catch (Throwable t) { + // Best-effort lookup. Any unexpected layout means we can't resolve via m2. + } + return null; + } + public static boolean isRunningInJDK() { if (!isRunningInJDKChecked) { isRunningInJDKChecked = true; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java index 02cdb8f3db..25599bf105 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CompileCSSMojo.java @@ -38,11 +38,27 @@ * * @author shannah */ -@Mojo(name = "css", defaultPhase = LifecyclePhase.PROCESS_RESOURCES, +@Mojo(name = "css", defaultPhase = LifecyclePhase.PROCESS_RESOURCES, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) public class CompileCSSMojo extends AbstractCN1Mojo { + /** + * Override the default DEBUG log level so the forked CSS compiler's stdout + * is visible in normal mvn output. When the CSS subprocess throws (e.g. + * StringIndexOutOfBoundsException in CN1CSSCLI), users currently only see + * the wrapper "An error occurred while compiling the CSS files" message + * with no usable detail unless they re-run with -X. + * + * Routed through createJava() (not the call site) so subclasses that + * override createJava() in tests still get to substitute their recording + * Java task without having to know about the log level. + */ + @Override + public org.apache.tools.ant.taskdefs.Java createJava() { + return createJava(org.apache.maven.doxia.logging.Log.LEVEL_INFO); + } + @Override protected void executeImpl() throws MojoExecutionException, MojoFailureException { @@ -232,11 +248,14 @@ private void executeImpl(String themePrefix) throws MojoExecutionException, Mojo - // Run the CSS compiler which is contained inside the codenameone-designer jar + // Run the CSS compiler which is contained inside the codenameone-designer jar. // NOTE: The codenameone-designer.jar is a dependency of the codenameone-maven-plugin as // zip file (which is the designer jar with all dependencies). We use this jar // rather than the central designer_1.jar located in the user's home directory to make it // easier to pin to a particular version. + // The Java task is created via createJava() (overridden in this class to use INFO log + // level) so subprocess output -- including stack traces from CN1CSSCLI failures -- + // shows up in normal mvn output instead of being hidden at DEBUG. Java java = createJava(); java.setDir(getCN1ProjectDir()); java.setJar(getDesignerJar()); diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 386604fe80..1c6a09fc1f 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -90,6 +90,7 @@ void writeProjectZip(OutputStream outputStream) throws IOException { copyZipEntriesToMap(template.CSS, mergedEntries, ZipEntryType.TEMPLATE_CSS); copyZipEntriesToMap(template.SOURCE_ZIP, mergedEntries, ZipEntryType.TEMPLATE_SOURCE); addLocalizationEntries(mergedEntries); + addAutoLocalizationBundleStub(mergedEntries); try (ZipOutputStream zos = new ZipOutputStream(outputStream)) { for (Map.Entry fileEntry : mergedEntries.entrySet()) { @@ -102,12 +103,53 @@ void writeProjectZip(OutputStream outputStream) throws IOException { } + /** + * Workaround for a bug in shipped Codename One versions (<= 7.0.236) where the + * simulator's AutoLocalizationBundle echoes any missing key back as its own value. + * UIManager.setBundle queries `@im` on every bundle install, gets `"@im"` back from + * the wormhole, tokenizes it, queries `"@im-@im"`, gets `"@im-@im"` back, then + * crashes inside parseTextFieldInputMode on substring(0, indexOf('=')) for a token + * with no `=`. The CSS compiler subprocess (CN1CSSCLI -> Display.init -> JavaSEPort.init + * -> enableAutoLocalizationBundle) hits this on every initializr-generated project. + * + * The proper fix lives in JavaSEPort.AutoLocalizationBundle (don't fabricate values + * for `@`-prefixed meta-keys), but that requires a new framework release. As a + * workaround we ship an empty `Bundle.properties` with `@im=`, which: + * 1. Is preferred by JavaSEPort.findDefaultLocalizationBundleFile over any other + * bundle file in src/main/l10n, so the AutoLocalizationBundle loads it as base. + * 2. Pre-populates `@im=""` in the bundle's underlying Hashtable, so + * AutoLocalizationBundle.get("@im") returns "" (not the fabricated "@im"), + * which has length 0, so setBundle skips the input-mode block entirely. + * + * This is unconditional (added to every generated project) because + * enableAutoLocalizationBundle auto-creates `src/main/l10n` even when the user + * didn't ask for localization bundles, so the crash hits projects without any + * localization too. Remove this stub once the framework fix has shipped and + * cn1.plugin.version is bumped past it. + */ + private void addAutoLocalizationBundleStub(Map mergedEntries) throws IOException { + String stub = "# Workaround for the simulator AutoLocalizationBundle @im fabrication crash\n" + + "# in Codename One <= 7.0.236. Once the framework fix ships, this file can be removed.\n" + + "# See GeneratorModel.addAutoLocalizationBundleStub for the full story.\n" + + "@im=\n"; + copySingleTextEntryToMap( + "common/src/main/l10n/Bundle.properties", + stub, + mergedEntries, + ZipEntryType.COMMON + ); + } + private void addLocalizationEntries(Map mergedEntries) throws IOException { if (!isBareTemplate() || !options.includeLocalizationBundles) { return; } + // The Codename One Maven plugin's CSS compiler scans src/main/l10n (or src/main/i18n) + // for *.properties bundles and bakes them into theme.res. If the bundles are placed + // anywhere else (e.g. src/main/resources) they are NOT baked into the resource file + // and Resources.getGlobalResources().getL10N("messages", lang) returns null at runtime. copySingleTextEntryToMap( - "common/src/main/resources/messages.properties", + "common/src/main/l10n/messages.properties", readResourceToString("/messages.properties"), mergedEntries, ZipEntryType.COMMON @@ -117,7 +159,7 @@ private void addLocalizationEntries(Map mergedEntries) throws IO continue; } copySingleTextEntryToMap( - "common/src/main/resources/messages_" + language.bundleSuffix + ".properties", + "common/src/main/l10n/messages_" + language.bundleSuffix + ".properties", readResourceToString("/messages_" + language.bundleSuffix + ".properties"), mergedEntries, ZipEntryType.COMMON @@ -356,8 +398,14 @@ private String injectJavaLocalizationBootstrap(String content) { + " public void init(Object context) {\n" + " super.init(context);\n" + " String language = L10NManager.getInstance().getLanguage();\n" - + " Hashtable bundle = Resources.getGlobalResources().getL10N(\"messages\", language);\n" - + " UIManager.getInstance().setBundle(bundle);\n" + + " Resources global = Resources.getGlobalResources();\n" + + " Hashtable bundle = global == null ? null : global.getL10N(\"messages\", language);\n" + + " if (bundle == null && global != null) {\n" + + " bundle = global.getL10N(\"messages\", \"\");\n" + + " }\n" + + " if (bundle != null) {\n" + + " UIManager.getInstance().setBundle(bundle);\n" + + " }\n" + " }\n\n"; int firstBrace = content.indexOf('{'); if (firstBrace > -1) { @@ -374,8 +422,14 @@ private String injectKotlinLocalizationBootstrap(String content) { String method = "\n override fun init(context: Any?) {\n" + " super.init(context)\n" + " val language = L10NManager.getInstance().language\n" - + " val bundle: Hashtable? = Resources.getGlobalResources().getL10N(\"messages\", language)\n" - + " UIManager.getInstance().setBundle(bundle)\n" + + " val global = Resources.getGlobalResources()\n" + + " var bundle: Hashtable? = global?.getL10N(\"messages\", language)\n" + + " if (bundle == null) {\n" + + " bundle = global?.getL10N(\"messages\", \"\")\n" + + " }\n" + + " if (bundle != null) {\n" + + " UIManager.getInstance().setBundle(bundle)\n" + + " }\n" + " }\n\n"; int firstBrace = content.indexOf('{'); if (firstBrace > -1) { diff --git a/scripts/initializr/common/src/main/resources/barebones-pom.xml b/scripts/initializr/common/src/main/resources/barebones-pom.xml index 45b112c92d..5367184adb 100644 --- a/scripts/initializr/common/src/main/resources/barebones-pom.xml +++ b/scripts/initializr/common/src/main/resources/barebones-pom.xml @@ -53,14 +53,18 @@ + diff --git a/scripts/initializr/common/src/main/resources/grub-pom.xml b/scripts/initializr/common/src/main/resources/grub-pom.xml index 2d0b619703..dabd70663a 100644 --- a/scripts/initializr/common/src/main/resources/grub-pom.xml +++ b/scripts/initializr/common/src/main/resources/grub-pom.xml @@ -62,12 +62,12 @@ diff --git a/scripts/initializr/common/src/main/resources/kotlin-pom.xml b/scripts/initializr/common/src/main/resources/kotlin-pom.xml index 012aacea7d..ca92d3951f 100644 --- a/scripts/initializr/common/src/main/resources/kotlin-pom.xml +++ b/scripts/initializr/common/src/main/resources/kotlin-pom.xml @@ -55,12 +55,12 @@ diff --git a/scripts/initializr/common/src/main/resources/tweet-pom.xml b/scripts/initializr/common/src/main/resources/tweet-pom.xml index 29d137a360..bb6d73b787 100644 --- a/scripts/initializr/common/src/main/resources/tweet-pom.xml +++ b/scripts/initializr/common/src/main/resources/tweet-pom.xml @@ -67,12 +67,12 @@ diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index 5779d205f6..46bd7289e2 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -2,12 +2,14 @@ import com.codename1.io.Util; import com.codename1.testing.AbstractTest; +import com.codename1.ui.util.Resources; import net.sf.zipme.ZipEntry; import net.sf.zipme.ZipInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; @@ -15,6 +17,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Hashtable; import java.util.List; import java.util.Locale; import java.util.Map; @@ -65,6 +68,38 @@ private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path java int exitCode = runMavenCompile(projectDir, homeDir, javaHome); assertTrue(exitCode == 0, "Generated project should compile with selected JDK. Version=" + version.label + " | exitCode=" + exitCode); + + // Localization bundles were requested -- they must end up baked into theme.res so + // that Resources.getGlobalResources().getL10N("messages", lang) resolves at runtime. + // This is the regression test for the NPE in MyAppName.init() reported when bundles + // were generated under common/src/main/resources instead of common/src/main/l10n. + assertLocalizationBakedIntoThemeRes(projectDir, version); + } + + private void assertLocalizationBakedIntoThemeRes(Path projectDir, ProjectOptions.JavaVersion version) throws Exception { + Path themeRes = projectDir.resolve("common/target/classes/theme.res"); + assertTrue(Files.isRegularFile(themeRes), + "theme.res should exist after compile. Version=" + version.label + " | path=" + themeRes); + + Resources res; + try (FileInputStream in = new FileInputStream(themeRes.toFile())) { + res = Resources.open(in); + } + + Hashtable defaultBundle = res.getL10N("messages", ""); + assertNotNull(defaultBundle, + "theme.res should contain a 'messages' L10N bundle for the default locale (\"\"). " + + "If null, bundles were not picked up by the CN1 css compiler -- check that " + + "they are placed under common/src/main/l10n. Version=" + version.label); + assertTrue(defaultBundle.size() > 0, + "Default 'messages' bundle should not be empty. Version=" + version.label); + + Hashtable hebrew = res.getL10N("messages", "he"); + assertNotNull(hebrew, + "theme.res should contain a Hebrew 'messages' bundle when localization bundles are requested. " + + "Version=" + version.label); + assertTrue(hebrew.size() > 0, + "Hebrew 'messages' bundle should not be empty. Version=" + version.label); } private byte[] createProjectZip(ProjectOptions options, String appName, String packageName) throws IOException { diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index b3a1e87c31..67c20a31ec 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -194,9 +194,19 @@ private void validateCombination(Template template, IDE ide) throws Exception { assertMainSourceFile(entries, template, packageName, mainClassName, false); assertThemeDefaults(entries, template); assertLocalizationBundles(entries, template, false); + assertAutoLocalizationBundleStub(entries); assertNoTemplatePlaceholders(entries, template); } + private void assertAutoLocalizationBundleStub(Map entries) { + // Workaround stub for the simulator AutoLocalizationBundle @im fabrication crash + // in shipped CN1 <= 7.0.236. Must be present on every generated project (with or + // without localization bundles) because enableAutoLocalizationBundle auto-creates + // src/main/l10n in the CSS compiler subprocess and hits the crash regardless. + String stub = getText(entries, "common/src/main/l10n/Bundle.properties"); + assertContains(stub, "@im=", "Generated project must ship Bundle.properties with @im= to suppress simulator wormhole crash"); + } + private void assertThemeDefaults(Map entries, Template template) { if (template != Template.BAREBONES && template != Template.KOTLIN) { return; @@ -345,18 +355,27 @@ private void assertMainSourceFile(Map entries, Template template private void assertLocalizationBundles(Map entries, Template template, boolean expectLocalizationBundles) { + // Bundles MUST live under common/src/main/l10n -- that's where the CN1 maven plugin's + // CSS compiler scans for properties files to bake into theme.res. Placing them under + // src/main/resources causes Resources.getGlobalResources().getL10N("messages", lang) + // to return null at runtime (see CompileCSSMojo.findLocalizationDirectory). if (template == Template.BAREBONES || template == Template.KOTLIN) { if (expectLocalizationBundles) { - assertNotNull(entries.get("common/src/main/resources/messages.properties"), "Barebones templates should include default localization bundle"); - assertNotNull(entries.get("common/src/main/resources/messages_ar.properties"), "Barebones templates should include Arabic localization bundle"); - assertNotNull(entries.get("common/src/main/resources/messages_he.properties"), "Barebones templates should include Hebrew localization bundle"); + assertNotNull(entries.get("common/src/main/l10n/messages.properties"), "Barebones templates should include default localization bundle under l10n"); + assertNotNull(entries.get("common/src/main/l10n/messages_ar.properties"), "Barebones templates should include Arabic localization bundle under l10n"); + assertNotNull(entries.get("common/src/main/l10n/messages_he.properties"), "Barebones templates should include Hebrew localization bundle under l10n"); + assertNull(entries.get("common/src/main/resources/messages.properties"), "Bundles must not be written to src/main/resources -- the CN1 plugin will not bake them into theme.res"); + assertNull(entries.get("common/src/main/resources/messages_ar.properties"), "Bundles must not be written to src/main/resources -- the CN1 plugin will not bake them into theme.res"); + assertNull(entries.get("common/src/main/resources/messages_he.properties"), "Bundles must not be written to src/main/resources -- the CN1 plugin will not bake them into theme.res"); } else { + assertNull(entries.get("common/src/main/l10n/messages.properties"), "Barebones templates should not include localization bundles by default"); + assertNull(entries.get("common/src/main/l10n/messages_ar.properties"), "Barebones templates should not include Arabic localization bundle by default"); + assertNull(entries.get("common/src/main/l10n/messages_he.properties"), "Barebones templates should not include Hebrew localization bundle by default"); assertNull(entries.get("common/src/main/resources/messages.properties"), "Barebones templates should not include localization bundles by default"); - assertNull(entries.get("common/src/main/resources/messages_ar.properties"), "Barebones templates should not include Arabic localization bundle by default"); - assertNull(entries.get("common/src/main/resources/messages_he.properties"), "Barebones templates should not include Hebrew localization bundle by default"); } return; } + assertNull(entries.get("common/src/main/l10n/messages.properties"), "Non-bare templates should not receive default localization bundle"); assertNull(entries.get("common/src/main/resources/messages.properties"), "Non-bare templates should not receive default localization bundle"); } diff --git a/tests/core/src/com/codename1/ui/util/ResourcesL10NTest.java b/tests/core/src/com/codename1/ui/util/ResourcesL10NTest.java new file mode 100644 index 0000000000..7b9a08425c --- /dev/null +++ b/tests/core/src/com/codename1/ui/util/ResourcesL10NTest.java @@ -0,0 +1,37 @@ +package com.codename1.ui.util; + +import com.codename1.testing.AbstractTest; + +import java.util.Hashtable; + +/** + * Regression test for Resources.getL10N / listL10NLocales / l10NLocaleSet + * returning null instead of throwing NullPointerException when a bundle id + * is not present in the .res file. + * + * Reported when an initializr-generated barebones project shipped its bundles + * under common/src/main/resources instead of common/src/main/l10n. The runtime + * lookup blew up at MyAppName.init -> Resources.getL10N because the resource id + * was missing from theme.res entirely. + */ +public class ResourcesL10NTest extends AbstractTest { + + @Override + public boolean runTest() throws Exception { + Resources empty = new Resources(); + + Hashtable bundle = empty.getL10N("missingBundle", "en"); + assertTrue(bundle == null, "getL10N must return null for an unknown bundle id, not throw NPE"); + + bundle = empty.getL10N("missingBundle", ""); + assertTrue(bundle == null, "getL10N must return null for an unknown bundle id with empty locale"); + + assertTrue(empty.listL10NLocales("missingBundle") == null, + "listL10NLocales must return null for an unknown bundle id, not throw NPE"); + + assertTrue(empty.l10NLocaleSet("missingBundle") == null, + "l10NLocaleSet must return null for an unknown bundle id, not throw NPE"); + + return true; + } +} diff --git a/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java b/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java index d25aeb30ee..4d9db36610 100644 --- a/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java +++ b/tests/core/test/com/codename1/impl/javase/AutoLocalizationBundleTest.java @@ -62,6 +62,21 @@ public boolean runTest() throws Exception { Map bundleReloadedMap = (Map) bundleReloaded; assertEqual("missingKey", bundleReloadedMap.get("missingKey"), "Existing persisted values should be loaded"); + // Regression: meta-keys (anything starting with `@`) must NOT be auto-fabricated. + // The auto-fabrication was breaking UIManager.setBundle, which queries `@im` / + // `@rtl` on every install and uses null-vs-non-null to mean "feature disabled". + // When the bundle echoed "@im" -> "@im", setBundle tokenized it, queried + // "@im-@im", got "@im-@im" back, and parseTextFieldInputMode crashed on + // substring(0, indexOf('=')) for a token with no '=' (issue #4850). + assertNull(bundleReloadedMap.get("@im"), "@-prefixed meta-keys must not be auto-fabricated"); + assertNull(bundleReloadedMap.get("@rtl"), "@-prefixed meta-keys must not be auto-fabricated"); + assertNull(bundleReloadedMap.get("@im-FOO"), "@-prefixed meta-keys must not be auto-fabricated"); + + // But real meta-key values that exist in the underlying file are still returned. + // Stage one by writing it through the explicit put path (which persists to disk). + bundleReloadedMap.put("@rtl", "true"); + assertEqual("true", bundleReloadedMap.get("@rtl"), "Existing meta-key values should still be returned"); + return true; } finally { deleteRecursive(tempDir);