From 7634d41b36bb673c3d9f5904095efa7a65e5eb62 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 19 May 2026 21:40:31 +0300 Subject: [PATCH 01/11] Simulator: data-driven menu hooks for cn1libs Adds a small framework feature that lets cn1libs contribute items to the JavaSE simulator's menu bar via a properties file, without referencing any Swing types. Each cn1lib drops a META-INF/codenameone/simulator-hooks.properties on its classpath: name=Bluetooth item1.label=Add demo peripheral item1.action=com.example.bt.sim.Hooks#addDemoPeripheral The new SimulatorHookLoader scans every jar on the simulator classpath via getResources(), parses the file, resolves each action's static method against the classloader that loaded Display, and pre-binds a Runnable that dispatches on the CN1 EDT. JavaSEPort.installMenu groups the result by menu name and renders one JMenu per group between the existing menus and the Help menu. Why data-driven instead of a Java SPI: the simulator UX is going to be rewritten and we don't want cn1libs to depend on JMenu/JMenuItem (or have to be recompiled when the UX shape changes). The neutral SimulatorHook record (menuName, label, Runnable) is the contract; the UI shell on top is replaceable. Tests in maven/javase cover well-formed parsing, declaration-order preservation, and skip-on-error for every malformed case (missing name, dangling label, unknown class, non-static target, malformed action string). Documentation lives in docs/developer-guide/Maven-Creating-CN1Libs.adoc with the cn1-bluetooth lib as a worked example. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/impl/javase/JavaSEPort.java | 32 +++ .../impl/javase/simulator/SimulatorHook.java | 51 ++++ .../javase/simulator/SimulatorHookLoader.java | 223 ++++++++++++++++++ .../Maven-Creating-CN1Libs.adoc | 115 +++++++++ .../simulator/SimulatorHookLoaderTest.java | 165 +++++++++++++ .../SimulatorHookLoaderTestFixture.java | 31 +++ 6 files changed, 617 insertions(+) create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index f4267c7eb7..e23804a272 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3784,6 +3784,35 @@ public void menuDeselected(MenuEvent e) { }); } + /** + * Discovers cn1lib-contributed simulator menu items via + * {@link SimulatorHookLoader} and groups them by menu name. The UX shell + * (this method, today) translates the neutral {@link SimulatorHook} list + * into Swing widgets; the contract that cn1libs depend on is the + * properties file + static method, not these JMenu/JMenuItem types. + */ + private List buildExtensionMenus() { + List hooks = SimulatorHookLoader.load(); + LinkedHashMap byName = new LinkedHashMap(); + for (final SimulatorHook hook : hooks) { + JMenu menu = byName.get(hook.getMenuName()); + if (menu == null) { + menu = new JMenu(hook.getMenuName()); + registerMenuWithBlit(menu); + byName.put(hook.getMenuName(), menu); + } + JMenuItem item = new JMenuItem(hook.getLabel()); + item.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + hook.getInvoke().run(); + } + }); + menu.add(item); + } + return new ArrayList(byName.values()); + } + private static Component findStatusBarComponent(Form f) { if (f == null || f.getToolbar() == null) { return null; @@ -5025,6 +5054,9 @@ public void actionPerformed(ActionEvent e) { bar.add(toolsMenu); bar.add(skinMenu); bar.add(createNativeThemeMenu(frm)); + for (JMenu extensionMenu : buildExtensionMenus()) { + bar.add(extensionMenu); + } bar.add(helpMenu); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java new file mode 100644 index 0000000000..8c1013839e --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores + * CA 94065 USA or visit www.oracle.com if you need additional information or + * have any questions. + */ +package com.codename1.impl.javase.simulator; + +/** + * One menu item contributed by a cn1lib (or the app) to the simulator. The UX + * shell decides how to render it (a JMenuItem today, possibly something else + * after the simulator UX is rewritten); contributors never reference any + * Swing types. + */ +public final class SimulatorHook { + private final String menuName; + private final String label; + private final Runnable invoke; + + public SimulatorHook(String menuName, String label, Runnable invoke) { + this.menuName = menuName; + this.label = label; + this.invoke = invoke; + } + + /** The grouping title (one per properties file). */ + public String getMenuName() { return menuName; } + + /** The display label for this item. */ + public String getLabel() { return label; } + + /** Invokes the configured static action on the CN1 EDT. */ + public Runnable getInvoke() { return invoke; } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java new file mode 100644 index 0000000000..840e3e54dd --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores + * CA 94065 USA or visit www.oracle.com if you need additional information or + * have any questions. + */ +package com.codename1.impl.javase.simulator; + +import com.codename1.ui.Display; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * Discovers cn1lib-contributed simulator menu items by scanning the classpath + * for {@code META-INF/codenameone/simulator-hooks.properties}. Each properties + * file contributes one named section worth of items: + * + *
+ * name=Bluetooth
+ *
+ * item1.label=Add peripheral...
+ * item1.action=com.example.bt.sim.Hooks#addPeripheral
+ *
+ * item2.label=Force disconnect
+ * item2.action=com.example.bt.sim.Hooks#forceDisconnect
+ * 
+ * + * Actions are resolved to {@code public static void method()} via reflection + * using the same classloader that loaded {@link Display}, so the method has + * full access to {@code Display}, {@code NativeLookup}-installed impls, and + * any cn1lib internals. The invocation is always dispatched on the CN1 EDT + * via {@link Display#callSerially(Runnable)} so hook authors can freely + * interact with the running app. + */ +public final class SimulatorHookLoader { + + private static final String RESOURCE_PATH = "META-INF/codenameone/simulator-hooks.properties"; + + private SimulatorHookLoader() {} + + /** + * Discovers all hooks visible to the JavaSE port's classloader (the one + * that loaded {@link Display}). Safe to call multiple times; each call + * re-scans the classpath. Errors in any single file (missing keys, + * unresolvable class, no such method) are logged and that entry is + * skipped; the rest are returned. + */ + public static List load() { + ClassLoader cl = Display.class.getClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + return load(cl); + } + + /** + * Same as {@link #load()} but scans an explicit classloader. Primary + * caller is tests that want to inject a fixture classpath; production + * code should prefer {@link #load()}. + */ + public static List load(ClassLoader cl) { + List out = new ArrayList(); + Enumeration urls; + try { + urls = cl.getResources(RESOURCE_PATH); + } catch (IOException ex) { + System.err.println("SimulatorHookLoader: failed to enumerate " + RESOURCE_PATH); + ex.printStackTrace(); + return out; + } + while (urls.hasMoreElements()) { + URL url = urls.nextElement(); + try { + loadOne(url, cl, out); + } catch (Throwable t) { + System.err.println("SimulatorHookLoader: failed to parse " + url); + t.printStackTrace(); + } + } + return out; + } + + private static void loadOne(URL url, ClassLoader cl, List out) throws IOException { + OrderedProperties props = new OrderedProperties(); + InputStream in = url.openStream(); + try { + // Reader form forces UTF-8; the default load(InputStream) is ISO-8859-1. + props.load(new BufferedReader(new InputStreamReader(in, "UTF-8"))); + } finally { + in.close(); + } + String menuName = props.getProperty("name"); + if (menuName == null || menuName.trim().length() == 0) { + System.err.println("SimulatorHookLoader: " + url + " is missing required 'name' property; skipping"); + return; + } + menuName = menuName.trim(); + + // Group keys by their "itemN" id, preserving declaration order. + LinkedHashMap labels = new LinkedHashMap(); + LinkedHashMap actions = new LinkedHashMap(); + for (String key : props.orderedKeys()) { + if (key.endsWith(".label")) { + labels.put(key.substring(0, key.length() - ".label".length()), props.getProperty(key)); + } else if (key.endsWith(".action")) { + actions.put(key.substring(0, key.length() - ".action".length()), props.getProperty(key)); + } + } + for (Map.Entry entry : labels.entrySet()) { + String id = entry.getKey(); + String label = entry.getValue(); + String action = actions.get(id); + if (label == null || label.trim().length() == 0) { + System.err.println("SimulatorHookLoader: " + url + " item '" + id + "' has empty label; skipping"); + continue; + } + if (action == null || action.trim().length() == 0) { + System.err.println("SimulatorHookLoader: " + url + " item '" + id + "' has no matching .action; skipping"); + continue; + } + Runnable invoke = buildInvoker(cl, action.trim(), url); + if (invoke == null) { + continue; + } + out.add(new SimulatorHook(menuName, label.trim(), invoke)); + } + } + + private static Runnable buildInvoker(ClassLoader cl, String action, URL source) { + int hash = action.indexOf('#'); + if (hash <= 0 || hash == action.length() - 1) { + System.err.println("SimulatorHookLoader: " + source + " has malformed action '" + action + "'; expected fqcn#methodName"); + return null; + } + String fqcn = action.substring(0, hash).trim(); + final String methodName = action.substring(hash + 1).trim(); + final Class targetClass; + final Method method; + try { + targetClass = Class.forName(fqcn, false, cl); + } catch (ClassNotFoundException ex) { + System.err.println("SimulatorHookLoader: " + source + " references unknown class '" + fqcn + "'"); + return null; + } + try { + method = targetClass.getDeclaredMethod(methodName, new Class[0]); + } catch (NoSuchMethodException ex) { + System.err.println("SimulatorHookLoader: " + source + " references unknown no-arg method '" + fqcn + "#" + methodName + "'"); + return null; + } + if (!java.lang.reflect.Modifier.isStatic(method.getModifiers())) { + System.err.println("SimulatorHookLoader: " + source + " references non-static method '" + fqcn + "#" + methodName + "'"); + return null; + } + method.setAccessible(true); + final URL src = source; + return new Runnable() { + @Override + public void run() { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + try { + method.invoke(null); + } catch (Throwable t) { + System.err.println("SimulatorHookLoader: action from " + src + " threw"); + t.printStackTrace(); + } + } + }); + } + }; + } + + /** + * Subclass of Properties that records insertion order so the menu reflects + * the order keys appear in the file rather than hash order. We only rely + * on {@code load(Reader)}, which routes through {@link #put(Object, Object)}. + */ + private static final class OrderedProperties extends Properties { + private final LinkedHashMap ordered = new LinkedHashMap(); + + @Override + public synchronized Object put(Object key, Object value) { + if (key instanceof String && value instanceof String) { + ordered.put((String) key, (String) value); + } + return super.put(key, value); + } + + List orderedKeys() { + return Collections.unmodifiableList(new ArrayList(ordered.keySet())); + } + } +} diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index aaa48d5b1d..e06f846a71 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -453,6 +453,121 @@ com.example.HelloWorld.helloWorld(); And build the project. The project should build OK, and if you run it, you should see that the `helloWorld()` method works as designed. +=== Adding menus to the simulator + +A cn1lib can contribute menu items to the Codename One simulator's menu bar. This is the same affordance the framework itself uses for the Skins / Native Theme / Simulate menus — opened up so library authors can expose backend-specific actions (e.g. "Add a simulated peripheral", "Inject a push notification", "Switch backend") without users having to write any Swing code or instrument their app. + +The same menu metadata is visible to CN1 unit tests, so a test can introspect what a library exposes and drive the same action methods directly. + +==== The contract + +Each cn1lib ships a properties file at a well-known classpath location. The simulator scans every jar on its classpath for this resource and merges the results, so multiple cn1libs coexist cleanly. + +[source,properties] +---- +# META-INF/codenameone/simulator-hooks.properties +name=Bluetooth + +item1.label=Toggle adapter on/off +item1.action=com.example.bt.simulator.Hooks#toggleAdapter + +item2.label=Add demo peripheral +item2.action=com.example.bt.simulator.Hooks#addDemoPeripheral +---- + +Required keys: + +`name`:: The menu title shown in the simulator's menu bar. One menu per properties file. +`itemN.label`:: Display text for an item. `itemN` is an opaque pairing token — any string works (`item1`, `add`, `42`) as long as the matching `.action` key uses the same prefix. Items appear in declaration order. +`itemN.action`:: A `fully.qualified.ClassName#staticMethodName` reference. The method must be `public static void` and take no arguments. + +No groups, no submenus, no priority — flat by design. If you need ordering relative to another cn1lib, you can't have it: discovery order wins, and that's intentional so the contract stays small and the future simulator UX can re-render this metadata however it likes. + +==== The action method + +The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can freely call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally. + +[source,java] +---- +package com.example.bt.simulator; + +import com.codename1.components.ToastBar; +import com.codename1.ui.Display; + +public final class Hooks { + public static void toggleAdapter() { + boolean next = !BluetoothSimulator.isEnabled(); + BluetoothSimulator.setEnabled(next); + if (Display.isInitialized()) { + ToastBar.showInfoMessage("Bluetooth adapter " + (next ? "ON" : "OFF")); + } + } +} +---- + +The `Display.isInitialized()` guard is a useful pattern when the same static methods are also called from JUnit tests that don't run inside a live CN1 simulator — the state mutation runs in both contexts, only the UI feedback is skipped. + +==== Worked example: cn1-bluetooth + +The `cn1-bluetooth` cn1lib ships a JavaSE port with two backends: a scriptable in-memory simulator and a real-hardware backend that talks to a native helper (CoreBluetooth on macOS, BlueZ on Linux, WinRT on Windows). Both are useful in different stages of development, and the menu lets the user choose between them and exercise the simulator without writing any test scaffolding. + +Its `simulator-hooks.properties` looks like this: + +[source,properties] +---- +name=Bluetooth + +item1.label=Toggle adapter on/off +item1.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter + +item2.label=Add demo peripheral +item2.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral + +item3.label=Disconnect all peripherals +item3.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#disconnectAll + +item4.label=Push demo notification +item4.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#pushDemoNotification + +item5.label=Clear peripherals +item5.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#clearPeripherals + +item6.label=Switch backend → native BLE (real hardware) +item6.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle + +item7.label=Switch backend → simulator +item7.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToSimulator +---- + +When the user runs an app that depends on `cn1-bluetooth`, the simulator's menu bar gets a new *Bluetooth* menu with these seven items. Clicking *Add demo peripheral* drops a peripheral into the in-memory simulator that the running app can then scan for, connect to, and exchange data with — without any real hardware. Clicking *Switch backend → native BLE* tears down the scriptable simulator and spins up the native helper so the same app code now scans real BLE peripherals around the developer's machine. + +==== Using the hooks from unit tests + +CN1 unit tests (`AbstractTest` subclasses run via `mvn cn1:test`) execute inside the simulator's classloader, so they see the same `simulator-hooks.properties` files the menu does. A test that wants to verify a hook's contract can introspect the registry directly: + +[source,java] +---- +import com.codename1.impl.javase.simulator.SimulatorHook; +import com.codename1.impl.javase.simulator.SimulatorHookLoader; + +// Inside runTest(): +List hooks = SimulatorHookLoader.load(); +for (SimulatorHook h : hooks) { + if ("Bluetooth".equals(h.getMenuName()) && "Add demo peripheral".equals(h.getLabel())) { + h.getInvoke().run(); // dispatches on the CN1 EDT + break; + } +} +---- + +In practice you usually don't need this — the same static methods on `BluetoothSimulatorHooks` (or your library's equivalent) are public, so the test can call them directly without going through the registry. The introspection path is mainly useful when you want to assert that a library's menu surface is what you expect. + +==== What's intentionally not exposed + +* *Swing types.* `JMenu`, `JMenuItem`, `KeyStroke` and friends do not appear in the contract. The simulator UX may change shape (toolbar, command palette, sidebar) and cn1libs shouldn't have to follow. +* *Submenus, separators, priority.* The metadata is a flat list with no hierarchy. If you want grouping, ship multiple `simulator-hooks.properties` files in separate jars — each becomes its own menu. +* *Long-running work on the EDT.* Hook methods run on the CN1 EDT; if you need to do I/O, fire-and-forget a `new Thread(...)` from the method or use `CN.invokeAndBlock` so you don't block the UI. + === Distributing your library The recommended way to distribute your library is on Maven central. That way users will be able to install your library by copying and pasting a familiar `` snippet into their pom.xml file. diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java new file mode 100644 index 0000000000..6864e4e2d5 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -0,0 +1,165 @@ +package com.codename1.impl.javase.simulator; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Parse-level coverage for {@link SimulatorHookLoader}. The loader's parser + * is the contract cn1libs depend on: a malformed properties file from one + * cn1lib must not poison the rest of the menu, and well-formed files must + * round-trip name/label/action faithfully. + * + * The CN1-EDT dispatch wrapper inside each Runnable is intentionally not + * exercised here (would require a running Display); the resolved {@code Method} + * is checked indirectly by relying on the loader to skip entries with + * unresolvable or non-static targets. + */ +class SimulatorHookLoaderTest { + + @Test + void parsesWellFormedFile(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "item1.label=Alpha\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "item2.label=Beta\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(2, hooks.size()); + assertEquals("Bluetooth", hooks.get(0).getMenuName()); + assertEquals("Alpha", hooks.get(0).getLabel()); + assertEquals("Bluetooth", hooks.get(1).getMenuName()); + assertEquals("Beta", hooks.get(1).getLabel()); + assertNotNull(hooks.get(0).getInvoke()); + assertNotNull(hooks.get(1).getInvoke()); + } + + @Test + void preservesDeclarationOrder(@TempDir Path tempDir) throws Exception { + // item3/item1/item2 in file order should appear in that order, not sorted. + writeProps(tempDir, "name=Bluetooth\n" + + "item3.label=Third\n" + + "item3.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "item1.label=First\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "item2.label=Second\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(3, hooks.size()); + assertEquals("Third", hooks.get(0).getLabel()); + assertEquals("First", hooks.get(1).getLabel()); + assertEquals("Second", hooks.get(2).getLabel()); + } + + @Test + void skipsFileWithoutName(@TempDir Path tempDir) throws Exception { + // Missing "name=" → entire file dropped, no exception. + writeProps(tempDir, "item1.label=Orphan\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertTrue(hooks.isEmpty(), "expected zero hooks but got: " + hooks); + } + + @Test + void skipsItemWithoutMatchingAction(@TempDir Path tempDir) throws Exception { + // item1 has label but no action; item2 is well-formed → only item2 survives. + writeProps(tempDir, "name=Bluetooth\n" + + "item1.label=Dangling\n" + + "item2.label=Beta\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertEquals("Beta", hooks.get(0).getLabel()); + } + + @Test + void skipsUnknownClassButKeepsRest(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "item1.label=Missing\n" + + "item1.action=com.example.DoesNotExist#nope\n" + + "item2.label=Alpha\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertEquals("Alpha", hooks.get(0).getLabel()); + } + + @Test + void skipsNonStaticMethod(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "item1.label=Instance\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#instanceOnly\n" + + "item2.label=Alpha\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertEquals("Alpha", hooks.get(0).getLabel()); + } + + @Test + void skipsMalformedActionString(@TempDir Path tempDir) throws Exception { + // No '#' separator at all. + writeProps(tempDir, "name=Bluetooth\n" + + "item1.label=Bad\n" + + "item1.action=not_a_method_reference\n" + + "item2.label=Alpha\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertEquals("Alpha", hooks.get(0).getLabel()); + } + + /** Writes the fixture to {@code /META-INF/codenameone/simulator-hooks.properties}. */ + private static void writeProps(Path tempDir, String content) throws Exception { + Path metaInf = tempDir.resolve("META-INF").resolve("codenameone"); + Files.createDirectories(metaInf); + File f = metaInf.resolve("simulator-hooks.properties").toFile(); + FileOutputStream out = new FileOutputStream(f); + try { + Writer w = new OutputStreamWriter(out, "UTF-8"); + w.write(content); + w.flush(); + } finally { + out.close(); + } + } + + /** + * Classloader whose only "extra" root is the temp dir, so + * {@code getResources("META-INF/codenameone/simulator-hooks.properties")} + * sees exactly the fixture and the fixture class is resolvable via parent + * delegation. Avoids polluting the surrounding test classpath with a + * resource that other tests would also discover. + */ + private static ClassLoader classloaderFor(Path tempDir) throws Exception { + URL url = tempDir.toUri().toURL(); + return new URLClassLoader(new URL[]{url}, SimulatorHookLoaderTest.class.getClassLoader()); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java new file mode 100644 index 0000000000..ee6a69e9ce --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java @@ -0,0 +1,31 @@ +package com.codename1.impl.javase.simulator; + +/** + * Static methods used by {@link SimulatorHookLoaderTest} as action targets. + * Kept on a separate class so the test can verify reflective resolution works + * for typical cn1lib-style entry points (a public class with public static + * void no-arg methods). + */ +public class SimulatorHookLoaderTestFixture { + + public static volatile int alphaCount; + public static volatile int betaCount; + + public static void alpha() { + alphaCount++; + } + + public static void beta() { + betaCount++; + } + + /** Not static — used to verify the loader rejects non-static methods. */ + public void instanceOnly() { + // intentionally empty + } + + static void resetCounters() { + alphaCount = 0; + betaCount = 0; + } +} From c8411c4a37b33dcbab70f75ae1ef59c605c2b4a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 19 May 2026 22:49:19 +0300 Subject: [PATCH 02/11] Vale: accept "backend" and clean up the new cn1lib doc section CI's vale gate flagged 10 prose issues in the simulator-menu-hooks section: "backend" hits Microsoft.Avoid (treated as a noise token since it's our standard term for a swappable implementation), "e.g.", a missing Oxford-style comma inside quoted enumerations, a stray "freely" adverb, heading punctuation, and "do not" instead of "don't". Adds "[Bb]ackend" to the project vocabulary and rewrites the affected sentences. No semantic change to the section. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/Maven-Creating-CN1Libs.adoc | 8 ++++---- .../styles/config/vocabularies/CodenameOne/accept.txt | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index e06f846a71..d3e35de67a 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -455,7 +455,7 @@ And build the project. The project should build OK, and if you run it, you shoul === Adding menus to the simulator -A cn1lib can contribute menu items to the Codename One simulator's menu bar. This is the same affordance the framework itself uses for the Skins / Native Theme / Simulate menus — opened up so library authors can expose backend-specific actions (e.g. "Add a simulated peripheral", "Inject a push notification", "Switch backend") without users having to write any Swing code or instrument their app. +A cn1lib can contribute menu items to the Codename One simulator's menu bar. This is the same affordance the framework itself uses for the Skins / Native Theme / Simulate menus — opened up so library authors can expose backend-specific actions (for example, "Add a simulated peripheral," "Inject a push notification," or "Switch backend") without users having to write any Swing code or instrument their app. The same menu metadata is visible to CN1 unit tests, so a test can introspect what a library exposes and drive the same action methods directly. @@ -485,7 +485,7 @@ No groups, no submenus, no priority — flat by design. If you need ordering rel ==== The action method -The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can freely call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally. +The simulator dispatches every action on the Codename One EDT through `Display.callSerially`, so your method can call `Display.getInstance()`, `Form.show()`, `Dialog.show()`, `ToastBar.showInfoMessage()` and any other CN1 API. Reflection uses the same classloader that loaded `Display`, so cn1lib internals (including package-private classes) resolve normally. [source,java] ---- @@ -507,7 +507,7 @@ public final class Hooks { The `Display.isInitialized()` guard is a useful pattern when the same static methods are also called from JUnit tests that don't run inside a live CN1 simulator — the state mutation runs in both contexts, only the UI feedback is skipped. -==== Worked example: cn1-bluetooth +==== Worked example using cn1-bluetooth The `cn1-bluetooth` cn1lib ships a JavaSE port with two backends: a scriptable in-memory simulator and a real-hardware backend that talks to a native helper (CoreBluetooth on macOS, BlueZ on Linux, WinRT on Windows). Both are useful in different stages of development, and the menu lets the user choose between them and exercise the simulator without writing any test scaffolding. @@ -564,7 +564,7 @@ In practice you usually don't need this — the same static methods on `Bluetoot ==== What's intentionally not exposed -* *Swing types.* `JMenu`, `JMenuItem`, `KeyStroke` and friends do not appear in the contract. The simulator UX may change shape (toolbar, command palette, sidebar) and cn1libs shouldn't have to follow. +* *Swing types.* `JMenu`, `JMenuItem`, `KeyStroke` and friends don't appear in the contract. The simulator UX may change shape (toolbar, command palette, sidebar) and cn1libs shouldn't have to follow. * *Submenus, separators, priority.* The metadata is a flat list with no hierarchy. If you want grouping, ship multiple `simulator-hooks.properties` files in separate jars — each becomes its own menu. * *Long-running work on the EDT.* Hook methods run on the CN1 EDT; if you need to do I/O, fire-and-forget a `new Thread(...)` from the method or use `CN.invokeAndBlock` so you don't block the UI. diff --git a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt index 57bc7fe9fd..d6333fc6c7 100644 --- a/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt +++ b/docs/developer-guide/styles/config/vocabularies/CodenameOne/accept.txt @@ -11,3 +11,4 @@ oversized Java ME Java SE Java EE +[Bb]ackend From c0178c801fcd3146f4c756a5b4f9f66224a49ee0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 06:42:29 +0300 Subject: [PATCH 03/11] Simulator hooks: add cross-platform CN.executeHook entry point Original feedback: CN1 tests live in the cross-platform common/ project and can't import JavaSE-port classes (no reflection, no JavaSE-only types). The previous design forced tests to either reflect on SimulatorHookLoader or directly instantiate cn1lib internals, both of which break on iOS/Android/JavaScript. This commit adds the missing piece: every hook gets a stable `namespace:id` identifier and is registered with a new core class, SimulatorHookExecutor. CN.executeHook(String hookId) delegates to that executor and returns false on platforms where the registry is empty (i.e., every non-simulator target). Tests in common/ can drive simulator-only behavior with one cross-platform call: CN.executeHook("bluetooth:addDemoPeripheral"); Also lifts the menu-label restriction: hooks may now declare an id and action without a label, in which case they are registered with the executor (callable from tests) but hidden from the simulator's menu. Useful for test fixture scaffolding ("seed N peripherals", "prime next-call failure") that would clutter the menu UX. Properties-file grammar additions: namespace= # defaults to slugified `name` itemN.id= # defaults to the property key (item1, item2) itemN.label=... # NOW OPTIONAL; absent = API-only hook Documentation in Maven-Creating-CN1Libs.adoc updated with the new shape and a CN.executeHook test example. 12 JUnit tests on the framework parser pass; existing menu rendering is unchanged for any hook that ships a label. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 110 +++++++++++++++++ CodenameOne/src/com/codename1/ui/CN.java | 37 ++++++ .../com/codename1/impl/javase/JavaSEPort.java | 6 + .../impl/javase/simulator/SimulatorHook.java | 39 ++++-- .../javase/simulator/SimulatorHookLoader.java | 112 ++++++++++++++---- .../Maven-Creating-CN1Libs.adoc | 75 +++++++----- .../simulator/SimulatorHookLoaderTest.java | 103 ++++++++++++++-- 7 files changed, 411 insertions(+), 71 deletions(-) create mode 100644 CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java new file mode 100644 index 0000000000..1e1c2206c2 --- /dev/null +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores + * CA 94065 USA or visit www.oracle.com if you need additional information or + * have any questions. + */ +package com.codename1.system; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Cross-platform registry of named actions that the JavaSE simulator exposes. + * + *

The simulator scans cn1libs (and the app itself) for + * {@code META-INF/codenameone/simulator-hooks.properties} files; each hook + * declared there is registered here under {@code namespace:id} keys. Tests + * and tooling that live in the cross-platform {@code common/} project can + * invoke a hook by id through {@link com.codename1.ui.CN#execute(String)} + * (which forwards to {@link #execute(String)}) without referencing any + * JavaSE-specific class.

+ * + *

On Android, iOS, JavaScript and other production targets this registry + * is always empty, so {@code execute} returns {@code false} — that's the + * "running outside a simulator" signal.

+ * + *

Hooks can be menu-backed (shipped with a label and visible in the + * simulator's menu bar) or API-only (no label, callable by id only). Tests + * that want behavioral coverage of cn1lib internals lean on the API-only + * form so the menu UX stays focused on actions a human would click.

+ */ +public final class SimulatorHookExecutor { + + private static volatile Map hooks = Collections.emptyMap(); + + private SimulatorHookExecutor() {} + + /** + * Invokes the action registered under {@code hookId}. Returns {@code true} + * if a hook with that id was found and dispatched, {@code false} + * otherwise. Invocation is delegated to whatever the registering code + * configured (the JavaSE port wraps each hook in + * {@code Display.callSerially}, so menu actions and tests run on the + * CN1 EDT). + * + * @param hookId opaque id of the form {@code namespace:hook} (the exact + * value the hook author chose in the properties file). + */ + public static boolean execute(String hookId) { + if (hookId == null) { + return false; + } + Runnable r = hooks.get(hookId); + if (r == null) { + return false; + } + r.run(); + return true; + } + + /** + * Returns {@code true} if a hook with the given id is registered. + * Useful for tests that want to skip themselves gracefully when running + * on a platform that doesn't expose the relevant cn1lib hook. + */ + public static boolean isRegistered(String hookId) { + return hookId != null && hooks.containsKey(hookId); + } + + /** + * Diagnostic view of every registered id. Returns an unmodifiable + * snapshot — never null. Intended for tests/inspectors; ordinary app + * code shouldn't need this. + */ + public static Collection registeredIds() { + return Collections.unmodifiableCollection(hooks.keySet()); + } + + /** + * Replaces the entire registry. The JavaSE port calls this every time + * it rebuilds the simulator menu (e.g., after a reload). On non-simulator + * targets nothing calls it and the registry stays empty. + */ + public static void register(Map registered) { + if (registered == null || registered.isEmpty()) { + hooks = Collections.emptyMap(); + return; + } + hooks = Collections.unmodifiableMap(new HashMap(registered)); + } +} diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index 62470ab8da..3d1e884046 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -696,6 +696,43 @@ public static void execute(String url) { Display.impl.execute(url); } + /// Invokes a named action registered with the JavaSE simulator's hook + /// system. cn1libs (and the app) declare hooks in + /// `META-INF/codenameone/simulator-hooks.properties` — the same file + /// that powers the simulator's Bluetooth/Push/etc. menus. Each hook + /// gets a stable id of the form `namespace:name`, e.g. + /// `bluetooth:toggleAdapter`. + /// + /// On Android, iOS, JavaScript, and other production targets this + /// method always returns `false` because no hooks are registered — + /// it's the explicit "we're not running in a simulator" signal that + /// CN1 UnitTest suites can branch on without referencing any + /// platform-specific class. + /// + /// Tests in the cross-platform `common/` project that want to drive + /// simulator behavior (toggle a simulated Bluetooth adapter, push a + /// scripted GPS fix, etc.) should call this instead of touching + /// JavaSE-port internals via reflection. + /// + /// ```java + /// // In a Codename One UnitTest: + /// CN.executeHook("bluetooth:addDemoPeripheral"); + /// // ...now drive the public Bluetooth API as usual. + /// ``` + /// + /// #### Parameters + /// + /// - `hookId`: the `namespace:name` identifier the hook was registered + /// under (see the cn1lib's `simulator-hooks.properties`). + /// + /// #### Returns + /// + /// - `true` if a hook with this id was found and dispatched; `false` + /// when no such hook is registered (always the case off-simulator). + public static boolean executeHook(String hookId) { + return com.codename1.system.SimulatorHookExecutor.execute(hookId); + } + /// Returns one of the density variables appropriate for this device, notice that /// density doesn't always correspond to resolution and an implementation might diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index e23804a272..7d9f1ac460 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3795,6 +3795,12 @@ private List buildExtensionMenus() { List hooks = SimulatorHookLoader.load(); LinkedHashMap byName = new LinkedHashMap(); for (final SimulatorHook hook : hooks) { + // API-only hooks (no label) are still registered with the + // executor so CN.executeHook can drive them, but they don't + // appear in the menu. + if (!hook.hasMenuLabel()) { + continue; + } JMenu menu = byName.get(hook.getMenuName()); if (menu == null) { menu = new JMenu(hook.getMenuName()); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java index 8c1013839e..8d84a8c234 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -24,28 +24,53 @@ package com.codename1.impl.javase.simulator; /** - * One menu item contributed by a cn1lib (or the app) to the simulator. The UX - * shell decides how to render it (a JMenuItem today, possibly something else - * after the simulator UX is rewritten); contributors never reference any - * Swing types. + * One named action contributed by a cn1lib (or the app) to the simulator. + * + *

A hook is always callable by id via + * {@link com.codename1.system.SimulatorHookExecutor#execute} (and from the + * cross-platform {@code CN.executeHook}). A hook with a non-empty label + * also appears in the simulator's menu bar; hooks without a label are + * API-only — useful for test scaffolding the user wouldn't click.

*/ public final class SimulatorHook { + private final String namespace; + private final String id; private final String menuName; private final String label; private final Runnable invoke; - public SimulatorHook(String menuName, String label, Runnable invoke) { + public SimulatorHook(String namespace, String id, String menuName, String label, Runnable invoke) { + this.namespace = namespace; + this.id = id; this.menuName = menuName; this.label = label; this.invoke = invoke; } - /** The grouping title (one per properties file). */ + /** Stable namespace token (one per properties file). */ + public String getNamespace() { return namespace; } + + /** Stable id within the namespace; the executor key is {@code namespace:id}. */ + public String getId() { return id; } + + /** Fully-qualified executor key — {@code namespace + ":" + id}. */ + public String getExecutorKey() { return namespace + ":" + id; } + + /** Display title of the menu this hook belongs to (one per properties file). */ public String getMenuName() { return menuName; } - /** The display label for this item. */ + /** + * Display label for the menu item, or {@code null}/empty if this hook is + * API-only (callable through {@link #getExecutorKey()} / {@code CN.executeHook} + * but invisible in the simulator menu). + */ public String getLabel() { return label; } /** Invokes the configured static action on the CN1 EDT. */ public Runnable getInvoke() { return invoke; } + + /** True if this hook should render as a menu item (label is non-empty). */ + public boolean hasMenuLabel() { + return label != null && label.trim().length() > 0; + } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index 840e3e54dd..6da1d94d60 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -23,6 +23,7 @@ */ package com.codename1.impl.javase.simulator; +import com.codename1.system.SimulatorHookExecutor; import com.codename1.ui.Display; import java.io.BufferedReader; import java.io.IOException; @@ -40,25 +41,34 @@ /** * Discovers cn1lib-contributed simulator menu items by scanning the classpath - * for {@code META-INF/codenameone/simulator-hooks.properties}. Each properties - * file contributes one named section worth of items: + * for {@code META-INF/codenameone/simulator-hooks.properties}. Each file + * contributes one named group of items: * *
  * name=Bluetooth
+ * namespace=bluetooth          # optional; defaults to slugified `name`
  *
- * item1.label=Add peripheral...
- * item1.action=com.example.bt.sim.Hooks#addPeripheral
+ * item1.id=toggleAdapter       # optional; defaults to the property key (item1)
+ * item1.label=Toggle adapter
+ * item1.action=com.example.bt.sim.Hooks#toggleAdapter
  *
- * item2.label=Force disconnect
- * item2.action=com.example.bt.sim.Hooks#forceDisconnect
+ * # Label omitted → API-only hook. Callable from tests via
+ * # CN.executeHook("bluetooth:internalTrigger"), invisible in the menu.
+ * item2.id=internalTrigger
+ * item2.action=com.example.bt.sim.Hooks#internalTrigger
  * 
* * Actions are resolved to {@code public static void method()} via reflection * using the same classloader that loaded {@link Display}, so the method has * full access to {@code Display}, {@code NativeLookup}-installed impls, and - * any cn1lib internals. The invocation is always dispatched on the CN1 EDT - * via {@link Display#callSerially(Runnable)} so hook authors can freely - * interact with the running app. + * any cn1lib internals. Invocations are dispatched on the CN1 EDT via + * {@link Display#callSerially(Runnable)} so hook authors can freely interact + * with the running app. + * + * In addition to returning the hook list for the menu, every successful load + * also registers each hook with {@link SimulatorHookExecutor} under its + * {@code namespace:id} key so the cross-platform + * {@link com.codename1.ui.CN#executeHook} entry point can drive it. */ public final class SimulatorHookLoader { @@ -69,9 +79,9 @@ private SimulatorHookLoader() {} /** * Discovers all hooks visible to the JavaSE port's classloader (the one * that loaded {@link Display}). Safe to call multiple times; each call - * re-scans the classpath. Errors in any single file (missing keys, - * unresolvable class, no such method) are logged and that entry is - * skipped; the rest are returned. + * re-scans the classpath and re-registers the executor. Errors in any + * single file (missing keys, unresolvable class, no such method) are + * logged and that entry is skipped; the rest are returned. */ public static List load() { ClassLoader cl = Display.class.getClassLoader(); @@ -94,6 +104,9 @@ public static List load(ClassLoader cl) { } catch (IOException ex) { System.err.println("SimulatorHookLoader: failed to enumerate " + RESOURCE_PATH); ex.printStackTrace(); + // Reset the registry so a previous load doesn't survive a + // failed scan (tests that mutate the classpath rely on this). + SimulatorHookExecutor.register(Collections.emptyMap()); return out; } while (urls.hasMoreElements()) { @@ -105,6 +118,12 @@ public static List load(ClassLoader cl) { t.printStackTrace(); } } + // Republish the registry so CN.executeHook reflects what we just loaded. + Map registered = new LinkedHashMap(); + for (SimulatorHook h : out) { + registered.put(h.getExecutorKey(), h.getInvoke()); + } + SimulatorHookExecutor.register(registered); return out; } @@ -123,35 +142,84 @@ private static void loadOne(URL url, ClassLoader cl, List out) th return; } menuName = menuName.trim(); + String namespace = props.getProperty("namespace"); + if (namespace == null || namespace.trim().length() == 0) { + namespace = slugify(menuName); + } else { + namespace = namespace.trim(); + } // Group keys by their "itemN" id, preserving declaration order. LinkedHashMap labels = new LinkedHashMap(); LinkedHashMap actions = new LinkedHashMap(); + LinkedHashMap ids = new LinkedHashMap(); for (String key : props.orderedKeys()) { if (key.endsWith(".label")) { labels.put(key.substring(0, key.length() - ".label".length()), props.getProperty(key)); } else if (key.endsWith(".action")) { actions.put(key.substring(0, key.length() - ".action".length()), props.getProperty(key)); + } else if (key.endsWith(".id")) { + ids.put(key.substring(0, key.length() - ".id".length()), props.getProperty(key)); } } - for (Map.Entry entry : labels.entrySet()) { - String id = entry.getKey(); - String label = entry.getValue(); - String action = actions.get(id); - if (label == null || label.trim().length() == 0) { - System.err.println("SimulatorHookLoader: " + url + " item '" + id + "' has empty label; skipping"); - continue; - } + + // The union of label-keys and id-keys is the set of declared items; + // items can be label-less (API-only) or label-only (no explicit id, + // defaults to the prefix). Walk in the order labels-then-id-only so + // menu items come first when both forms appear in the same file. + LinkedHashMap itemIds = new LinkedHashMap(); + for (String prefix : labels.keySet()) itemIds.put(prefix, Boolean.TRUE); + for (String prefix : ids.keySet()) { + if (!itemIds.containsKey(prefix)) itemIds.put(prefix, Boolean.TRUE); + } + + for (String prefix : itemIds.keySet()) { + String action = actions.get(prefix); if (action == null || action.trim().length() == 0) { - System.err.println("SimulatorHookLoader: " + url + " item '" + id + "' has no matching .action; skipping"); + System.err.println("SimulatorHookLoader: " + url + " item '" + prefix + + "' has no matching .action; skipping"); continue; } + String label = labels.get(prefix); // may be null (API-only) + if (label != null) label = label.trim(); + String explicitId = ids.get(prefix); + String id = explicitId != null && explicitId.trim().length() > 0 + ? explicitId.trim() + : prefix; Runnable invoke = buildInvoker(cl, action.trim(), url); if (invoke == null) { continue; } - out.add(new SimulatorHook(menuName, label.trim(), invoke)); + out.add(new SimulatorHook(namespace, id, menuName, label, invoke)); + } + } + + /** + * Reduces a free-form menu name to a stable, ASCII-only namespace token. + * Used when the properties file doesn't declare {@code namespace=...} + * explicitly. Lowercases letters, keeps digits, replaces anything else + * with a single hyphen, trims leading/trailing hyphens. + */ + static String slugify(String name) { + StringBuilder sb = new StringBuilder(name.length()); + boolean lastDash = true; + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 'A' && c <= 'Z') { + sb.append((char)(c + 32)); + lastDash = false; + } else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + sb.append(c); + lastDash = false; + } else if (!lastDash) { + sb.append('-'); + lastDash = true; + } } + // Strip trailing dash. + int end = sb.length(); + while (end > 0 && sb.charAt(end - 1) == '-') end--; + return sb.substring(0, end); } private static Runnable buildInvoker(ClassLoader cl, String action, URL source) { diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index d3e35de67a..f99f8bcc36 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -457,7 +457,7 @@ And build the project. The project should build OK, and if you run it, you shoul A cn1lib can contribute menu items to the Codename One simulator's menu bar. This is the same affordance the framework itself uses for the Skins / Native Theme / Simulate menus — opened up so library authors can expose backend-specific actions (for example, "Add a simulated peripheral," "Inject a push notification," or "Switch backend") without users having to write any Swing code or instrument their app. -The same menu metadata is visible to CN1 unit tests, so a test can introspect what a library exposes and drive the same action methods directly. +Each menu item also gets a stable id, and a CN1 UnitTest in the cross-platform `common/` project can invoke that id through `CN.executeHook("namespace:id")` without referencing any JavaSE-specific class. The same mechanism supports hooks with *no menu label* — useful when you want a callable from tests but no visible menu entry. ==== The contract @@ -467,20 +467,31 @@ Each cn1lib ships a properties file at a well-known classpath location. The simu ---- # META-INF/codenameone/simulator-hooks.properties name=Bluetooth +namespace=bluetooth # optional; defaults to slugified `name` +# Item with label = visible in the menu AND callable via CN.executeHook +item1.id=toggleAdapter item1.label=Toggle adapter on/off item1.action=com.example.bt.simulator.Hooks#toggleAdapter -item2.label=Add demo peripheral -item2.action=com.example.bt.simulator.Hooks#addDemoPeripheral +# Item without label = API-only; callable from tests, invisible in the menu +item2.id=scriptFault +item2.action=com.example.bt.simulator.Hooks#scriptFault ---- Required keys: `name`:: The menu title shown in the simulator's menu bar. One menu per properties file. -`itemN.label`:: Display text for an item. `itemN` is an opaque pairing token — any string works (`item1`, `add`, `42`) as long as the matching `.action` key uses the same prefix. Items appear in declaration order. `itemN.action`:: A `fully.qualified.ClassName#staticMethodName` reference. The method must be `public static void` and take no arguments. +Optional keys: + +`namespace`:: Stable identifier for the `CN.executeHook` lookup. Defaults to lowercased, ASCII-slugified `name` (`Push Notifications!` → `push-notifications`). Set this explicitly when you want a different identifier from the display name. +`itemN.label`:: Display text for the menu entry. Omit entirely to make the hook API-only — registered with `CN.executeHook` but hidden from the menu. +`itemN.id`:: Stable identifier inside the namespace. Defaults to the property key (`item1`, `item2`, etc.). Set this to something meaningful when you expect tests to call the hook by name. + +The `itemN` part of each property key is an opaque pairing token — any string works (`item1`, `add`, `42`) as long as the matching `.action`/`.label`/`.id` keys use the same prefix. Items appear in declaration order. + No groups, no submenus, no priority — flat by design. If you need ordering relative to another cn1lib, you can't have it: discovery order wins, and that's intentional so the contract stays small and the future simulator UX can re-render this metadata however it likes. ==== The action method @@ -516,51 +527,55 @@ Its `simulator-hooks.properties` looks like this: [source,properties] ---- name=Bluetooth +namespace=bluetooth +item1.id=toggleAdapter item1.label=Toggle adapter on/off item1.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter +item2.id=addDemoPeripheral item2.label=Add demo peripheral item2.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral -item3.label=Disconnect all peripherals -item3.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#disconnectAll - -item4.label=Push demo notification -item4.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#pushDemoNotification +item3.id=switchToNativeBle +item3.label=Switch backend → native BLE (real hardware) +item3.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle -item5.label=Clear peripherals -item5.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#clearPeripherals - -item6.label=Switch backend → native BLE (real hardware) -item6.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle - -item7.label=Switch backend → simulator -item7.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToSimulator +# API-only: used by the test suite but never displayed in the menu +item4.id=primeFailure +item4.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#primeFailure ---- -When the user runs an app that depends on `cn1-bluetooth`, the simulator's menu bar gets a new *Bluetooth* menu with these seven items. Clicking *Add demo peripheral* drops a peripheral into the in-memory simulator that the running app can then scan for, connect to, and exchange data with — without any real hardware. Clicking *Switch backend → native BLE* tears down the scriptable simulator and spins up the native helper so the same app code now scans real BLE peripherals around the developer's machine. +When the user runs an app that depends on `cn1-bluetooth`, the simulator's menu bar gets a *Bluetooth* menu with the three labeled items. Clicking *Add demo peripheral* drops a peripheral into the in-memory simulator that the running app can then scan for, connect to, and exchange data with — without any real hardware. -==== Using the hooks from unit tests +==== Calling hooks from CN1 unit tests -CN1 unit tests (`AbstractTest` subclasses run via `mvn cn1:test`) execute inside the simulator's classloader, so they see the same `simulator-hooks.properties` files the menu does. A test that wants to verify a hook's contract can introspect the registry directly: +CN1 unit tests (`AbstractTest` subclasses run via `mvn cn1:test`) compile under the same restrictions as the rest of the app — no reflection, no JavaSE-only imports. To drive a simulator hook from a test, call `CN.executeHook` with the `namespace:id` of the hook you want to fire: [source,java] ---- -import com.codename1.impl.javase.simulator.SimulatorHook; -import com.codename1.impl.javase.simulator.SimulatorHookLoader; - -// Inside runTest(): -List hooks = SimulatorHookLoader.load(); -for (SimulatorHook h : hooks) { - if ("Bluetooth".equals(h.getMenuName()) && "Add demo peripheral".equals(h.getLabel())) { - h.getInvoke().run(); // dispatches on the CN1 EDT - break; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.CN; + +public class BluetoothDemoTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + // Seed the simulator: same effect as a human clicking the menu item. + if (!CN.executeHook("bluetooth:addDemoPeripheral")) { + // Outside a CN1 simulator (e.g. running on a device): bail out + // gracefully — the hook isn't registered, so we can't script + // the state, and the test is irrelevant on real hardware. + return true; + } + // ...now drive the public Bluetooth API as usual. + return true; } } ---- -In practice you usually don't need this — the same static methods on `BluetoothSimulatorHooks` (or your library's equivalent) are public, so the test can call them directly without going through the registry. The introspection path is mainly useful when you want to assert that a library's menu surface is what you expect. +`CN.executeHook` returns `false` on platforms that don't expose the hook (Android, iOS, JavaScript, plain `java -jar` invocations), so tests can fall back cleanly. The hook always runs on the CN1 EDT. + +A common pattern: ship `_label_`-bearing hooks for actions a developer might want to fire manually (toggle adapter, inject notification), and ship label-less hooks for test-only state setup (`primeFailure`, `seedFixture`) that would just clutter the menu. ==== What's intentionally not exposed diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java index 6864e4e2d5..44ed6b937b 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -1,5 +1,6 @@ package com.codename1.impl.javase.simulator; +import com.codename1.system.SimulatorHookExecutor; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -11,22 +12,25 @@ import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * Parse-level coverage for {@link SimulatorHookLoader}. The loader's parser * is the contract cn1libs depend on: a malformed properties file from one * cn1lib must not poison the rest of the menu, and well-formed files must - * round-trip name/label/action faithfully. + * round-trip name/label/action/namespace/id faithfully. * - * The CN1-EDT dispatch wrapper inside each Runnable is intentionally not - * exercised here (would require a running Display); the resolved {@code Method} - * is checked indirectly by relying on the loader to skip entries with - * unresolvable or non-static targets. + * The {@code Display.callSerially} dispatch wrapper inside each Runnable + * is intentionally not exercised here (would require a running Display); + * the resolved {@code Method} is checked indirectly by relying on the + * loader to skip entries with unresolvable or non-static targets. */ class SimulatorHookLoaderTest { @@ -41,12 +45,42 @@ void parsesWellFormedFile(@TempDir Path tempDir) throws Exception { List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); assertEquals(2, hooks.size()); - assertEquals("Bluetooth", hooks.get(0).getMenuName()); - assertEquals("Alpha", hooks.get(0).getLabel()); - assertEquals("Bluetooth", hooks.get(1).getMenuName()); + SimulatorHook first = hooks.get(0); + assertEquals("Bluetooth", first.getMenuName()); + assertEquals("bluetooth", first.getNamespace(), "namespace should default to slugified name"); + assertEquals("item1", first.getId(), "id should default to property prefix"); + assertEquals("Alpha", first.getLabel()); + assertEquals("bluetooth:item1", first.getExecutorKey()); + assertTrue(first.hasMenuLabel()); + assertNotNull(first.getInvoke()); assertEquals("Beta", hooks.get(1).getLabel()); - assertNotNull(hooks.get(0).getInvoke()); - assertNotNull(hooks.get(1).getInvoke()); + } + + @Test + void honorsExplicitNamespaceAndId(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\n" + + "item1.id=toggleAdapter\n" + + "item1.label=Toggle\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertEquals("bt", hooks.get(0).getNamespace()); + assertEquals("toggleAdapter", hooks.get(0).getId()); + assertEquals("bt:toggleAdapter", hooks.get(0).getExecutorKey()); + } + + @Test + void slugifiesMultiWordName(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Push Notifications!\n" + + "item1.label=Send\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals("push-notifications", hooks.get(0).getNamespace()); } @Test @@ -68,9 +102,46 @@ void preservesDeclarationOrder(@TempDir Path tempDir) throws Exception { assertEquals("Second", hooks.get(2).getLabel()); } + @Test + void apiOnlyHookHasNullLabelButIsCallable(@TempDir Path tempDir) throws Exception { + // Label-less item: registered with the executor, hidden from the menu. + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\n" + + "item1.id=script\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + SimulatorHookLoaderTestFixture.resetCounters(); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size()); + assertFalse(hooks.get(0).hasMenuLabel(), "label-less item must be hidden from menu"); + assertNull(hooks.get(0).getLabel()); + // ...and via the cross-platform entry point: + assertTrue(SimulatorHookExecutor.execute("bt:script")); + } + + @Test + void executorReceivesEveryRegisteredHook(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\n" + + "item1.id=alpha\n" + + "item1.label=Alpha\n" + + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "item2.id=beta\n" + + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); + + SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertTrue(SimulatorHookExecutor.isRegistered("bt:alpha")); + assertTrue(SimulatorHookExecutor.isRegistered("bt:beta")); + assertFalse(SimulatorHookExecutor.isRegistered("bt:unknown")); + assertFalse(SimulatorHookExecutor.execute("bt:unknown"), + "execute() must return false for unknown ids without throwing"); + } + @Test void skipsFileWithoutName(@TempDir Path tempDir) throws Exception { - // Missing "name=" → entire file dropped, no exception. writeProps(tempDir, "item1.label=Orphan\n" + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); @@ -81,7 +152,6 @@ void skipsFileWithoutName(@TempDir Path tempDir) throws Exception { @Test void skipsItemWithoutMatchingAction(@TempDir Path tempDir) throws Exception { - // item1 has label but no action; item2 is well-formed → only item2 survives. writeProps(tempDir, "name=Bluetooth\n" + "item1.label=Dangling\n" + "item2.label=Beta\n" @@ -136,6 +206,15 @@ void skipsMalformedActionString(@TempDir Path tempDir) throws Exception { assertEquals("Alpha", hooks.get(0).getLabel()); } + @Test + void slugifyHandlesEdgeCases() { + assertEquals("bluetooth", SimulatorHookLoader.slugify("Bluetooth")); + assertEquals("push-notifications", SimulatorHookLoader.slugify("Push Notifications!")); + assertEquals("a-b-c", SimulatorHookLoader.slugify("A__B__C")); + assertEquals("foo123", SimulatorHookLoader.slugify("foo123")); + assertEquals("", SimulatorHookLoader.slugify("###")); + } + /** Writes the fixture to {@code /META-INF/codenameone/simulator-hooks.properties}. */ private static void writeProps(Path tempDir, String content) throws Exception { Path metaInf = tempDir.resolve("META-INF").resolve("codenameone"); From ae1d40a5d5a2a33835cce004ab5ea60479f96924 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 06:47:29 +0300 Subject: [PATCH 04/11] Simulator hooks: dispatch synchronously when called from off-EDT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action wrapper used Display.callSerially, which is fire-and-forget. That broke CN.executeHook callers running off the EDT (every CN1 UnitTest's runTest()) — they'd assert state changes before the EDT had run the action and false-fail. Switched to Display.callSeriallyAndWait. From the EDT the body runs inline (CN1's existing semantics); from any other thread the call blocks until the action completes. CN.executeHook now returns true only after the hook has actually executed, so tests can immediately assert on the side effects. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../impl/javase/simulator/SimulatorHookLoader.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index 6da1d94d60..da4948872d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -253,7 +253,13 @@ private static Runnable buildInvoker(ClassLoader cl, String action, URL source) return new Runnable() { @Override public void run() { - Display.getInstance().callSerially(new Runnable() { + // callSeriallyAndWait so a caller off the EDT (e.g. a CN1 + // UnitTest's runTest() running on the main thread) blocks + // until the hook completes; otherwise the test would + // assert state changes before the EDT got to run the + // action. On the EDT itself, callSeriallyAndWait runs the + // body inline without re-entering the dispatch queue. + Display.getInstance().callSeriallyAndWait(new Runnable() { @Override public void run() { try { From 372a666e4a490a21f7160dbe245d63dcba9fc434 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 07:12:32 +0300 Subject: [PATCH 05/11] Style: use /// markdown comments in SimulatorHookExecutor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framework's docs-style gate (build-test JDK 8/17/21) rejects classic /** Javadoc markers in core/CLDC classes — CN1 standardized on Java 25 /// markdown comments instead. Convert SimulatorHookExecutor to the project style. No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 84 +++++++++---------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 1e1c2206c2..3f7b3ce15e 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -28,43 +28,41 @@ import java.util.HashMap; import java.util.Map; -/** - * Cross-platform registry of named actions that the JavaSE simulator exposes. - * - *

The simulator scans cn1libs (and the app itself) for - * {@code META-INF/codenameone/simulator-hooks.properties} files; each hook - * declared there is registered here under {@code namespace:id} keys. Tests - * and tooling that live in the cross-platform {@code common/} project can - * invoke a hook by id through {@link com.codename1.ui.CN#execute(String)} - * (which forwards to {@link #execute(String)}) without referencing any - * JavaSE-specific class.

- * - *

On Android, iOS, JavaScript and other production targets this registry - * is always empty, so {@code execute} returns {@code false} — that's the - * "running outside a simulator" signal.

- * - *

Hooks can be menu-backed (shipped with a label and visible in the - * simulator's menu bar) or API-only (no label, callable by id only). Tests - * that want behavioral coverage of cn1lib internals lean on the API-only - * form so the menu UX stays focused on actions a human would click.

- */ +/// Cross-platform registry of named actions that the JavaSE simulator exposes. +/// +/// The simulator scans cn1libs (and the app itself) for +/// `META-INF/codenameone/simulator-hooks.properties` files; each hook +/// declared there is registered here under `namespace:id` keys. Tests +/// and tooling that live in the cross-platform `common/` project can +/// invoke a hook by id through [com.codename1.ui.CN#executeHook(String)] +/// (which forwards to [#execute(String)]) without referencing any +/// JavaSE-specific class. +/// +/// On Android, iOS, JavaScript and other production targets this registry +/// is always empty, so `execute` returns `false` — that's the +/// "running outside a simulator" signal. +/// +/// Hooks can be menu-backed (shipped with a label and visible in the +/// simulator's menu bar) or API-only (no label, callable by id only). Tests +/// that want behavioral coverage of cn1lib internals lean on the API-only +/// form so the menu UX stays focused on actions a human would click. public final class SimulatorHookExecutor { private static volatile Map hooks = Collections.emptyMap(); private SimulatorHookExecutor() {} - /** - * Invokes the action registered under {@code hookId}. Returns {@code true} - * if a hook with that id was found and dispatched, {@code false} - * otherwise. Invocation is delegated to whatever the registering code - * configured (the JavaSE port wraps each hook in - * {@code Display.callSerially}, so menu actions and tests run on the - * CN1 EDT). - * - * @param hookId opaque id of the form {@code namespace:hook} (the exact - * value the hook author chose in the properties file). - */ + /// Invokes the action registered under `hookId`. Returns `true` + /// if a hook with that id was found and dispatched, `false` + /// otherwise. Invocation is delegated to whatever the registering code + /// configured (the JavaSE port wraps each hook in + /// `Display.callSeriallyAndWait`, so menu actions and tests run on the + /// CN1 EDT and the call is synchronous from off-EDT callers). + /// + /// #### Parameters + /// + /// - `hookId`: opaque id of the form `namespace:hook` (the exact + /// value the hook author chose in the properties file). public static boolean execute(String hookId) { if (hookId == null) { return false; @@ -77,29 +75,23 @@ public static boolean execute(String hookId) { return true; } - /** - * Returns {@code true} if a hook with the given id is registered. - * Useful for tests that want to skip themselves gracefully when running - * on a platform that doesn't expose the relevant cn1lib hook. - */ + /// Returns `true` if a hook with the given id is registered. + /// Useful for tests that want to skip themselves gracefully when running + /// on a platform that doesn't expose the relevant cn1lib hook. public static boolean isRegistered(String hookId) { return hookId != null && hooks.containsKey(hookId); } - /** - * Diagnostic view of every registered id. Returns an unmodifiable - * snapshot — never null. Intended for tests/inspectors; ordinary app - * code shouldn't need this. - */ + /// Diagnostic view of every registered id. Returns an unmodifiable + /// snapshot — never null. Intended for tests/inspectors; ordinary app + /// code shouldn't need this. public static Collection registeredIds() { return Collections.unmodifiableCollection(hooks.keySet()); } - /** - * Replaces the entire registry. The JavaSE port calls this every time - * it rebuilds the simulator menu (e.g., after a reload). On non-simulator - * targets nothing calls it and the registry stays empty. - */ + /// Replaces the entire registry. The JavaSE port calls this every time + /// it rebuilds the simulator menu (e.g., after a reload). On non-simulator + /// targets nothing calls it and the registry stays empty. public static void register(Map registered) { if (registered == null || registered.isEmpty()) { hooks = Collections.emptyMap(); From 085b47e2b7d0a353def541f581f0eba45146b384 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 07:39:48 +0300 Subject: [PATCH 06/11] Simulator hooks: reuse Display.execute + positional itemN/labelN format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous design added CN.executeHook + nested itemN.id/.label/.action keys. Both miss the mark: - We already have CN/Display.execute(String url) for native dispatch on every platform; the JavaSE port can intercept hook urls before handing the rest to the OS browser. Adding executeHook duplicates that surface and forces app code to learn a second method. - Items are positional: item1 is the first menu entry, item2 the second. The previous design treated itemN as an opaque pairing token (any string worked) and allowed reordering — that's wrong; the simulator UX renders in numeric order and the loader should too. The loop now stops at the first missing itemN, matching what the user-facing menu would do. - The compound key syntax (itemN.label, itemN.action, itemN.id) is redundant when the namespace is already in the file's `name`/ `namespace` field. Replaced with parallel arrays: itemN holds the action, labelN holds the label. Final shape: name=Bluetooth namespace=bluetooth # optional; defaults to slugified `name` item1=fqcn#method # required; the Nth menu item's action label1=Toggle adapter # optional; if absent, hook is API-only item2=fqcn#method2 label2=Add demo peripheral # No labelN -> registered with the executor but not in the menu item3=fqcn#primeReadFailure JavaSEPort.execute intercepts urls matching a registered hook (key shape: "namespace:itemN") and routes them through the existing SimulatorHookExecutor; non-matching urls fall through to the browser launcher. JavaSEPort.canExecute reports TRUE for registered hook urls so tests can guard cross-platform. SimulatorHookExecutor stays in core. Tests use CN.execute(...) plus CN.canExecute(...) as a "are we in a simulator?" gate; no JavaSE-only imports needed. 12 JUnit tests pin the positional loop, slugify rules, gap-stops behavior, executor registration, and the API-only branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/ui/CN.java | 53 ++---- .../com/codename1/impl/javase/JavaSEPort.java | 20 ++- .../impl/javase/simulator/SimulatorHook.java | 28 +-- .../javase/simulator/SimulatorHookLoader.java | 147 ++++++---------- .../Maven-Creating-CN1Libs.adoc | 66 ++++---- .../simulator/SimulatorHookLoaderTest.java | 159 +++++++++--------- 6 files changed, 212 insertions(+), 261 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index 3d1e884046..77bdc10ba8 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -678,7 +678,16 @@ public static Boolean canExecute(String url) { return Display.impl.canExecute(url); } - /// Executes the given URL on the native platform + /// Executes the given URL on the native platform. Also serves as the + /// cross-platform entry point for the JavaSE simulator's hook system: + /// the simulator scans cn1libs for `META-INF/codenameone/simulator-hooks.properties`, + /// and a URL of the form `namespace:itemN` that matches a registered hook + /// is intercepted by the JavaSE port and dispatched on the CN1 EDT + /// instead of being handed to the native URL opener. On Android, iOS, + /// JavaScript and other production targets no hooks are ever registered, + /// so a hook-style URL falls through to the normal native execute and + /// (almost always) becomes a no-op — tests should guard with + /// [#canExecute(String)] when running cross-platform. /// /// ```java /// Boolean can = Display.getInstance().canExecute("imdb:///find?q=godfather"); @@ -687,6 +696,11 @@ public static Boolean canExecute(String url) { /// } else { /// Display.getInstance().execute("http://www.imdb.com"); /// } + /// + /// // Driving a cn1lib's simulator hook from a CN1 UnitTest: + /// if (Boolean.TRUE.equals(CN.canExecute("bluetooth:item1"))) { + /// CN.execute("bluetooth:item1"); // toggle the simulated adapter + /// } /// ``` /// /// #### Parameters @@ -696,43 +710,6 @@ public static void execute(String url) { Display.impl.execute(url); } - /// Invokes a named action registered with the JavaSE simulator's hook - /// system. cn1libs (and the app) declare hooks in - /// `META-INF/codenameone/simulator-hooks.properties` — the same file - /// that powers the simulator's Bluetooth/Push/etc. menus. Each hook - /// gets a stable id of the form `namespace:name`, e.g. - /// `bluetooth:toggleAdapter`. - /// - /// On Android, iOS, JavaScript, and other production targets this - /// method always returns `false` because no hooks are registered — - /// it's the explicit "we're not running in a simulator" signal that - /// CN1 UnitTest suites can branch on without referencing any - /// platform-specific class. - /// - /// Tests in the cross-platform `common/` project that want to drive - /// simulator behavior (toggle a simulated Bluetooth adapter, push a - /// scripted GPS fix, etc.) should call this instead of touching - /// JavaSE-port internals via reflection. - /// - /// ```java - /// // In a Codename One UnitTest: - /// CN.executeHook("bluetooth:addDemoPeripheral"); - /// // ...now drive the public Bluetooth API as usual. - /// ``` - /// - /// #### Parameters - /// - /// - `hookId`: the `namespace:name` identifier the hook was registered - /// under (see the cn1lib's `simulator-hooks.properties`). - /// - /// #### Returns - /// - /// - `true` if a hook with this id was found and dispatched; `false` - /// when no such hook is registered (always the case off-simulator). - public static boolean executeHook(String hookId) { - return com.codename1.system.SimulatorHookExecutor.execute(hookId); - } - /// Returns one of the density variables appropriate for this device, notice that /// density doesn't always correspond to resolution and an implementation might diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 7d9f1ac460..3b52984361 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -10321,13 +10321,22 @@ private void launchBrowserThatWorks(String url) { * @inheritDoc */ public void execute(String url) { + // Simulator-only intercept: a URL that matches a registered + // SimulatorHookExecutor entry is dispatched as a named hook + // (e.g. "bluetooth:item1") instead of being handed to the + // OS URL opener. SimulatorHookExecutor.execute returns false + // when no such hook is registered, so non-hook URLs fall + // through to the normal native behavior. + if (url != null && com.codename1.system.SimulatorHookExecutor.execute(url.trim())) { + return; + } try { url = url.trim(); if(url.startsWith("file:")) { if(!checkForPermission("android.permission.WRITE_EXTERNAL_STORAGE", "This is required to open the file")){ return; } - + url = new File(unfile(url)).toURI().toURL().toExternalForm(); } final String fUrl = url; @@ -10336,7 +10345,7 @@ public void run() { launchBrowserThatWorks(fUrl); } }); - + } catch (Exception ex) { ex.printStackTrace(); } @@ -14855,6 +14864,13 @@ public boolean isJailbrokenDevice() { @Override public Boolean canExecute(String url) { + // If this is a registered simulator hook URL, report it as + // executable up-front so a cross-platform CN1 UnitTest can use + // CN.canExecute(...) to gate hook calls behind a "we're in the + // simulator" check without exception-handling. + if (url != null && com.codename1.system.SimulatorHookExecutor.isRegistered(url.trim())) { + return Boolean.TRUE; + } if(!url.startsWith("http")) { int pos = url.indexOf(":"); if(pos > -1) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java index 8d84a8c234..fa4355460b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -24,24 +24,24 @@ package com.codename1.impl.javase.simulator; /** - * One named action contributed by a cn1lib (or the app) to the simulator. + * One positional action contributed by a cn1lib (or the app) to the simulator. * - *

A hook is always callable by id via - * {@link com.codename1.system.SimulatorHookExecutor#execute} (and from the - * cross-platform {@code CN.executeHook}). A hook with a non-empty label - * also appears in the simulator's menu bar; hooks without a label are - * API-only — useful for test scaffolding the user wouldn't click.

+ *

Hooks are positional within each {@code simulator-hooks.properties} + * file: {@code item1} is the first menu entry, {@code item2} the second, + * etc. A hook with a non-empty {@link #getLabel() label} renders as a menu + * item; a label-less hook is API-only — invisible in the menu but still + * callable via {@code CN.execute("namespace:itemN")} for test scaffolding.

*/ public final class SimulatorHook { private final String namespace; - private final String id; + private final int index; private final String menuName; private final String label; private final Runnable invoke; - public SimulatorHook(String namespace, String id, String menuName, String label, Runnable invoke) { + public SimulatorHook(String namespace, int index, String menuName, String label, Runnable invoke) { this.namespace = namespace; - this.id = id; + this.index = index; this.menuName = menuName; this.label = label; this.invoke = invoke; @@ -50,18 +50,18 @@ public SimulatorHook(String namespace, String id, String menuName, String label, /** Stable namespace token (one per properties file). */ public String getNamespace() { return namespace; } - /** Stable id within the namespace; the executor key is {@code namespace:id}. */ - public String getId() { return id; } + /** 1-based position of this item within its properties file. */ + public int getIndex() { return index; } - /** Fully-qualified executor key — {@code namespace + ":" + id}. */ - public String getExecutorKey() { return namespace + ":" + id; } + /** URL passed to {@code CN.execute} to trigger this hook — {@code namespace + ":item" + index}. */ + public String getExecutorKey() { return namespace + ":item" + index; } /** Display title of the menu this hook belongs to (one per properties file). */ public String getMenuName() { return menuName; } /** * Display label for the menu item, or {@code null}/empty if this hook is - * API-only (callable through {@link #getExecutorKey()} / {@code CN.executeHook} + * API-only (callable through {@link #getExecutorKey()} / {@code CN.execute} * but invisible in the simulator menu). */ public String getLabel() { return label; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index da4948872d..21eaef184d 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -42,33 +42,37 @@ /** * Discovers cn1lib-contributed simulator menu items by scanning the classpath * for {@code META-INF/codenameone/simulator-hooks.properties}. Each file - * contributes one named group of items: + * declares one named group of positional items: * *
  * name=Bluetooth
  * namespace=bluetooth          # optional; defaults to slugified `name`
  *
- * item1.id=toggleAdapter       # optional; defaults to the property key (item1)
- * item1.label=Toggle adapter
- * item1.action=com.example.bt.sim.Hooks#toggleAdapter
+ * item1=com.example.bt.sim.Hooks#toggleAdapter
+ * label1=Toggle adapter
+ *
+ * item2=com.example.bt.sim.Hooks#addDemoPeripheral
+ * label2=Add demo peripheral
  *
  * # Label omitted → API-only hook. Callable from tests via
- * # CN.executeHook("bluetooth:internalTrigger"), invisible in the menu.
- * item2.id=internalTrigger
- * item2.action=com.example.bt.sim.Hooks#internalTrigger
+ * # CN.execute("bluetooth:item3"), invisible in the menu.
+ * item3=com.example.bt.sim.Hooks#primeReadFailure
  * 
* - * Actions are resolved to {@code public static void method()} via reflection - * using the same classloader that loaded {@link Display}, so the method has - * full access to {@code Display}, {@code NativeLookup}-installed impls, and - * any cn1lib internals. Invocations are dispatched on the CN1 EDT via - * {@link Display#callSerially(Runnable)} so hook authors can freely interact - * with the running app. + *

Items are positional: the loader reads {@code item1}, {@code item2}, + * {@code item3}, ... and stops at the first missing {@code itemN}. Each + * {@code itemN} value is a {@code fqcn#staticMethodName} reference; the + * matching {@code labelN} (optional) becomes the menu item text.

+ * + *

Actions are resolved to {@code public static void method()} via reflection + * using the same classloader that loaded {@link Display}. Invocations are + * dispatched via {@link Display#callSeriallyAndWait(Runnable)} so menu clicks + * and {@code CN.execute} from off-EDT test code both run on the CN1 EDT and + * are synchronous to the caller.

* - * In addition to returning the hook list for the menu, every successful load - * also registers each hook with {@link SimulatorHookExecutor} under its - * {@code namespace:id} key so the cross-platform - * {@link com.codename1.ui.CN#executeHook} entry point can drive it. + *

Every successful load also registers each hook with {@link SimulatorHookExecutor} + * under {@code "namespace:itemN"} keys so {@link com.codename1.ui.CN#execute(String)} + * can drive it cross-platform.

*/ public final class SimulatorHookLoader { @@ -104,8 +108,6 @@ public static List load(ClassLoader cl) { } catch (IOException ex) { System.err.println("SimulatorHookLoader: failed to enumerate " + RESOURCE_PATH); ex.printStackTrace(); - // Reset the registry so a previous load doesn't survive a - // failed scan (tests that mutate the classpath rely on this). SimulatorHookExecutor.register(Collections.emptyMap()); return out; } @@ -118,7 +120,7 @@ public static List load(ClassLoader cl) { t.printStackTrace(); } } - // Republish the registry so CN.executeHook reflects what we just loaded. + // Republish the registry so CN.execute reflects what we just loaded. Map registered = new LinkedHashMap(); for (SimulatorHook h : out) { registered.put(h.getExecutorKey(), h.getInvoke()); @@ -128,7 +130,7 @@ public static List load(ClassLoader cl) { } private static void loadOne(URL url, ClassLoader cl, List out) throws IOException { - OrderedProperties props = new OrderedProperties(); + Properties props = new Properties(); InputStream in = url.openStream(); try { // Reader form forces UTF-8; the default load(InputStream) is ISO-8859-1. @@ -149,48 +151,26 @@ private static void loadOne(URL url, ClassLoader cl, List out) th namespace = namespace.trim(); } - // Group keys by their "itemN" id, preserving declaration order. - LinkedHashMap labels = new LinkedHashMap(); - LinkedHashMap actions = new LinkedHashMap(); - LinkedHashMap ids = new LinkedHashMap(); - for (String key : props.orderedKeys()) { - if (key.endsWith(".label")) { - labels.put(key.substring(0, key.length() - ".label".length()), props.getProperty(key)); - } else if (key.endsWith(".action")) { - actions.put(key.substring(0, key.length() - ".action".length()), props.getProperty(key)); - } else if (key.endsWith(".id")) { - ids.put(key.substring(0, key.length() - ".id".length()), props.getProperty(key)); - } - } - - // The union of label-keys and id-keys is the set of declared items; - // items can be label-less (API-only) or label-only (no explicit id, - // defaults to the prefix). Walk in the order labels-then-id-only so - // menu items come first when both forms appear in the same file. - LinkedHashMap itemIds = new LinkedHashMap(); - for (String prefix : labels.keySet()) itemIds.put(prefix, Boolean.TRUE); - for (String prefix : ids.keySet()) { - if (!itemIds.containsKey(prefix)) itemIds.put(prefix, Boolean.TRUE); - } - - for (String prefix : itemIds.keySet()) { - String action = actions.get(prefix); + // Items are positional: read item1, item2, ... and stop at the + // first missing N. labelN is optional (no label = API-only hook). + int index = 1; + while (true) { + String action = props.getProperty("item" + index); if (action == null || action.trim().length() == 0) { - System.err.println("SimulatorHookLoader: " + url + " item '" + prefix - + "' has no matching .action; skipping"); - continue; + break; } - String label = labels.get(prefix); // may be null (API-only) - if (label != null) label = label.trim(); - String explicitId = ids.get(prefix); - String id = explicitId != null && explicitId.trim().length() > 0 - ? explicitId.trim() - : prefix; - Runnable invoke = buildInvoker(cl, action.trim(), url); - if (invoke == null) { - continue; + String label = props.getProperty("label" + index); + if (label != null) { + label = label.trim(); + if (label.length() == 0) { + label = null; + } } - out.add(new SimulatorHook(namespace, id, menuName, label, invoke)); + Runnable invoke = buildInvoker(cl, action.trim(), url, index); + if (invoke != null) { + out.add(new SimulatorHook(namespace, index, menuName, label, invoke)); + } + index++; } } @@ -216,16 +196,16 @@ static String slugify(String name) { lastDash = true; } } - // Strip trailing dash. int end = sb.length(); while (end > 0 && sb.charAt(end - 1) == '-') end--; return sb.substring(0, end); } - private static Runnable buildInvoker(ClassLoader cl, String action, URL source) { + private static Runnable buildInvoker(ClassLoader cl, String action, URL source, int index) { int hash = action.indexOf('#'); if (hash <= 0 || hash == action.length() - 1) { - System.err.println("SimulatorHookLoader: " + source + " has malformed action '" + action + "'; expected fqcn#methodName"); + System.err.println("SimulatorHookLoader: " + source + " item" + index + + " has malformed action '" + action + "'; expected fqcn#methodName"); return null; } String fqcn = action.substring(0, hash).trim(); @@ -235,17 +215,20 @@ private static Runnable buildInvoker(ClassLoader cl, String action, URL source) try { targetClass = Class.forName(fqcn, false, cl); } catch (ClassNotFoundException ex) { - System.err.println("SimulatorHookLoader: " + source + " references unknown class '" + fqcn + "'"); + System.err.println("SimulatorHookLoader: " + source + " item" + index + + " references unknown class '" + fqcn + "'"); return null; } try { method = targetClass.getDeclaredMethod(methodName, new Class[0]); } catch (NoSuchMethodException ex) { - System.err.println("SimulatorHookLoader: " + source + " references unknown no-arg method '" + fqcn + "#" + methodName + "'"); + System.err.println("SimulatorHookLoader: " + source + " item" + index + + " references unknown no-arg method '" + fqcn + "#" + methodName + "'"); return null; } if (!java.lang.reflect.Modifier.isStatic(method.getModifiers())) { - System.err.println("SimulatorHookLoader: " + source + " references non-static method '" + fqcn + "#" + methodName + "'"); + System.err.println("SimulatorHookLoader: " + source + " item" + index + + " references non-static method '" + fqcn + "#" + methodName + "'"); return null; } method.setAccessible(true); @@ -253,12 +236,11 @@ private static Runnable buildInvoker(ClassLoader cl, String action, URL source) return new Runnable() { @Override public void run() { - // callSeriallyAndWait so a caller off the EDT (e.g. a CN1 - // UnitTest's runTest() running on the main thread) blocks - // until the hook completes; otherwise the test would - // assert state changes before the EDT got to run the - // action. On the EDT itself, callSeriallyAndWait runs the - // body inline without re-entering the dispatch queue. + // callSeriallyAndWait so off-EDT callers (every CN1 UnitTest's + // runTest()) block until the hook completes — tests would + // otherwise assert state changes before the EDT got to run + // the action. On the EDT, callSeriallyAndWait runs the body + // inline without re-entering the dispatch queue. Display.getInstance().callSeriallyAndWait(new Runnable() { @Override public void run() { @@ -273,25 +255,4 @@ public void run() { } }; } - - /** - * Subclass of Properties that records insertion order so the menu reflects - * the order keys appear in the file rather than hash order. We only rely - * on {@code load(Reader)}, which routes through {@link #put(Object, Object)}. - */ - private static final class OrderedProperties extends Properties { - private final LinkedHashMap ordered = new LinkedHashMap(); - - @Override - public synchronized Object put(Object key, Object value) { - if (key instanceof String && value instanceof String) { - ordered.put((String) key, (String) value); - } - return super.put(key, value); - } - - List orderedKeys() { - return Collections.unmodifiableList(new ArrayList(ordered.keySet())); - } - } } diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index f99f8bcc36..c35820ffa7 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -457,7 +457,7 @@ And build the project. The project should build OK, and if you run it, you shoul A cn1lib can contribute menu items to the Codename One simulator's menu bar. This is the same affordance the framework itself uses for the Skins / Native Theme / Simulate menus — opened up so library authors can expose backend-specific actions (for example, "Add a simulated peripheral," "Inject a push notification," or "Switch backend") without users having to write any Swing code or instrument their app. -Each menu item also gets a stable id, and a CN1 UnitTest in the cross-platform `common/` project can invoke that id through `CN.executeHook("namespace:id")` without referencing any JavaSE-specific class. The same mechanism supports hooks with *no menu label* — useful when you want a callable from tests but no visible menu entry. +Every menu item is also reachable from CN1 UnitTests (or any app code) through `CN.execute("namespace:itemN")` — the JavaSE port's URL execute is overloaded to recognize a registered hook url and dispatch it on the EDT instead of opening it as a browser URL. On Android, iOS, JavaScript and other production targets no hooks are registered, so a hook-style URL falls through to the normal native execute and (almost always) becomes a no-op; tests should pair `CN.execute` with `CN.canExecute` for that reason. Hooks can also be declared with no menu label, which makes them callable from tests but invisible in the menu — useful for state-priming actions a human wouldn't click. ==== The contract @@ -469,28 +469,28 @@ Each cn1lib ships a properties file at a well-known classpath location. The simu name=Bluetooth namespace=bluetooth # optional; defaults to slugified `name` -# Item with label = visible in the menu AND callable via CN.executeHook -item1.id=toggleAdapter -item1.label=Toggle adapter on/off -item1.action=com.example.bt.simulator.Hooks#toggleAdapter +# Each itemN is the action; the matching labelN is the menu text. +# Items are positional — the loader reads item1, item2, item3, ... and +# stops at the first missing index. Don't skip numbers. +item1=com.example.bt.simulator.Hooks#toggleAdapter +label1=Toggle adapter on/off -# Item without label = API-only; callable from tests, invisible in the menu -item2.id=scriptFault -item2.action=com.example.bt.simulator.Hooks#scriptFault +item2=com.example.bt.simulator.Hooks#addDemoPeripheral +label2=Add demo peripheral + +# Label omitted → API-only hook. Callable from tests, invisible in menu. +item3=com.example.bt.simulator.Hooks#primeReadFailure ---- Required keys: -`name`:: The menu title shown in the simulator's menu bar. One menu per properties file. -`itemN.action`:: A `fully.qualified.ClassName#staticMethodName` reference. The method must be `public static void` and take no arguments. +`name`:: Menu title shown in the simulator's menu bar. One menu per properties file. +`itemN`:: A `fully.qualified.ClassName#staticMethodName` reference for the Nth menu item. The method must be `public static void` and take no arguments. Items are numbered from 1 upward; the loader stops at the first missing `itemN`, so don't leave gaps. Optional keys: -`namespace`:: Stable identifier for the `CN.executeHook` lookup. Defaults to lowercased, ASCII-slugified `name` (`Push Notifications!` → `push-notifications`). Set this explicitly when you want a different identifier from the display name. -`itemN.label`:: Display text for the menu entry. Omit entirely to make the hook API-only — registered with `CN.executeHook` but hidden from the menu. -`itemN.id`:: Stable identifier inside the namespace. Defaults to the property key (`item1`, `item2`, etc.). Set this to something meaningful when you expect tests to call the hook by name. - -The `itemN` part of each property key is an opaque pairing token — any string works (`item1`, `add`, `42`) as long as the matching `.action`/`.label`/`.id` keys use the same prefix. Items appear in declaration order. +`namespace`:: Identifier used for the `CN.execute` lookup, e.g. `bluetooth` for a URL like `bluetooth:item1`. Defaults to lowercased, ASCII-slugified `name` (`Push Notifications!` → `push-notifications`). Set this explicitly when you want a different identifier from the display name. +`labelN`:: Display text for the matching `itemN`. Omit entirely to make the hook API-only — registered with `CN.execute` but hidden from the menu. No groups, no submenus, no priority — flat by design. If you need ordering relative to another cn1lib, you can't have it: discovery order wins, and that's intentional so the contract stays small and the future simulator UX can re-render this metadata however it likes. @@ -529,28 +529,24 @@ Its `simulator-hooks.properties` looks like this: name=Bluetooth namespace=bluetooth -item1.id=toggleAdapter -item1.label=Toggle adapter on/off -item1.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter +item1=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter +label1=Toggle adapter on/off -item2.id=addDemoPeripheral -item2.label=Add demo peripheral -item2.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral +item2=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral +label2=Add demo peripheral -item3.id=switchToNativeBle -item3.label=Switch backend → native BLE (real hardware) -item3.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle +item3=com.codename1.bluetoothle.BluetoothSimulatorHooks#switchToNativeBle +label3=Switch backend → native BLE (real hardware) # API-only: used by the test suite but never displayed in the menu -item4.id=primeFailure -item4.action=com.codename1.bluetoothle.BluetoothSimulatorHooks#primeFailure +item4=com.codename1.bluetoothle.BluetoothSimulatorHooks#primeReadFailure ---- -When the user runs an app that depends on `cn1-bluetooth`, the simulator's menu bar gets a *Bluetooth* menu with the three labeled items. Clicking *Add demo peripheral* drops a peripheral into the in-memory simulator that the running app can then scan for, connect to, and exchange data with — without any real hardware. +When the user runs an app that depends on `cn1-bluetooth`, the simulator's menu bar gets a *Bluetooth* menu with the three labeled items. Clicking *Add demo peripheral* drops a peripheral into the in-memory simulator that the running app can then scan for, connect to, and exchange data with — without any real hardware. `item4` is callable from tests via `CN.execute("bluetooth:item4")` but never shows up in the menu. ==== Calling hooks from CN1 unit tests -CN1 unit tests (`AbstractTest` subclasses run via `mvn cn1:test`) compile under the same restrictions as the rest of the app — no reflection, no JavaSE-only imports. To drive a simulator hook from a test, call `CN.executeHook` with the `namespace:id` of the hook you want to fire: +CN1 unit tests (`AbstractTest` subclasses run via `mvn cn1:test`) compile under the same restrictions as the rest of the app — no reflection, no JavaSE-only imports. Drive a hook the same way you'd execute any URL — `CN.execute` recognizes registered hook urls and dispatches them on the EDT: [source,java] ---- @@ -560,22 +556,20 @@ import com.codename1.ui.CN; public class BluetoothDemoTest extends AbstractTest { @Override public boolean runTest() throws Exception { - // Seed the simulator: same effect as a human clicking the menu item. - if (!CN.executeHook("bluetooth:addDemoPeripheral")) { - // Outside a CN1 simulator (e.g. running on a device): bail out - // gracefully — the hook isn't registered, so we can't script - // the state, and the test is irrelevant on real hardware. + // Skip cleanly off-simulator: a real device has no hook registered + // and CN.canExecute will not return TRUE. + if (!Boolean.TRUE.equals(CN.canExecute("bluetooth:item2"))) { return true; } + // Seed the simulator — same effect as clicking "Add demo peripheral". + CN.execute("bluetooth:item2"); // ...now drive the public Bluetooth API as usual. return true; } } ---- -`CN.executeHook` returns `false` on platforms that don't expose the hook (Android, iOS, JavaScript, plain `java -jar` invocations), so tests can fall back cleanly. The hook always runs on the CN1 EDT. - -A common pattern: ship `_label_`-bearing hooks for actions a developer might want to fire manually (toggle adapter, inject notification), and ship label-less hooks for test-only state setup (`primeFailure`, `seedFixture`) that would just clutter the menu. +A common pattern: ship label-bearing hooks for actions a developer might want to fire manually (toggle adapter, inject notification), and ship label-less hooks for test-only state setup (`primeReadFailure`, `seedFixture`) that would just clutter the menu. ==== What's intentionally not exposed diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java index 44ed6b937b..9e46916c1b 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -12,7 +12,6 @@ import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -22,25 +21,28 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Parse-level coverage for {@link SimulatorHookLoader}. The loader's parser - * is the contract cn1libs depend on: a malformed properties file from one - * cn1lib must not poison the rest of the menu, and well-formed files must - * round-trip name/label/action/namespace/id faithfully. + * Parse-level coverage for {@link SimulatorHookLoader}. * - * The {@code Display.callSerially} dispatch wrapper inside each Runnable - * is intentionally not exercised here (would require a running Display); - * the resolved {@code Method} is checked indirectly by relying on the - * loader to skip entries with unresolvable or non-static targets. + *

The contract cn1libs depend on: items are positional ({@code item1}, + * {@code item2}, ...), the loop stops at the first missing index, labels + * are optional (API-only), and the resulting executor keys are exactly + * {@code namespace:itemN}. JavaSE-side {@code Display.execute} intercepts + * those keys and routes them to the hook.

+ * + *

The {@code Display.callSeriallyAndWait} dispatch wrapper inside each + * Runnable is intentionally not exercised here (would require a running + * Display); the resolved {@code Method} is checked indirectly by relying + * on the loader to skip entries with unresolvable or non-static targets.

*/ class SimulatorHookLoaderTest { @Test void parsesWellFormedFile(@TempDir Path tempDir) throws Exception { writeProps(tempDir, "name=Bluetooth\n" - + "item1.label=Alpha\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" - + "item2.label=Beta\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Alpha\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n" + + "label2=Beta\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); @@ -48,102 +50,114 @@ void parsesWellFormedFile(@TempDir Path tempDir) throws Exception { SimulatorHook first = hooks.get(0); assertEquals("Bluetooth", first.getMenuName()); assertEquals("bluetooth", first.getNamespace(), "namespace should default to slugified name"); - assertEquals("item1", first.getId(), "id should default to property prefix"); + assertEquals(1, first.getIndex()); assertEquals("Alpha", first.getLabel()); assertEquals("bluetooth:item1", first.getExecutorKey()); assertTrue(first.hasMenuLabel()); assertNotNull(first.getInvoke()); + assertEquals(2, hooks.get(1).getIndex()); assertEquals("Beta", hooks.get(1).getLabel()); + assertEquals("bluetooth:item2", hooks.get(1).getExecutorKey()); } @Test - void honorsExplicitNamespaceAndId(@TempDir Path tempDir) throws Exception { + void honorsExplicitNamespace(@TempDir Path tempDir) throws Exception { writeProps(tempDir, "name=Bluetooth\n" + "namespace=bt\n" - + "item1.id=toggleAdapter\n" - + "item1.label=Toggle\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Toggle\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); assertEquals(1, hooks.size()); assertEquals("bt", hooks.get(0).getNamespace()); - assertEquals("toggleAdapter", hooks.get(0).getId()); - assertEquals("bt:toggleAdapter", hooks.get(0).getExecutorKey()); + assertEquals("bt:item1", hooks.get(0).getExecutorKey()); } @Test void slugifiesMultiWordName(@TempDir Path tempDir) throws Exception { writeProps(tempDir, "name=Push Notifications!\n" - + "item1.label=Send\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Send\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); assertEquals("push-notifications", hooks.get(0).getNamespace()); + assertEquals("push-notifications:item1", hooks.get(0).getExecutorKey()); } @Test - void preservesDeclarationOrder(@TempDir Path tempDir) throws Exception { - // item3/item1/item2 in file order should appear in that order, not sorted. + void itemsAreReadInPositionalOrder(@TempDir Path tempDir) throws Exception { + // Even if listed in the file out of numeric order, positional iteration + // visits item1 then item2 then item3. writeProps(tempDir, "name=Bluetooth\n" - + "item3.label=Third\n" - + "item3.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" - + "item1.label=First\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" - + "item2.label=Second\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item3=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label3=Third\n" + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=First\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label2=Second\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); assertEquals(3, hooks.size()); - assertEquals("Third", hooks.get(0).getLabel()); - assertEquals("First", hooks.get(1).getLabel()); - assertEquals("Second", hooks.get(2).getLabel()); + assertEquals("First", hooks.get(0).getLabel()); + assertEquals("Second", hooks.get(1).getLabel()); + assertEquals("Third", hooks.get(2).getLabel()); + } + + @Test + void loopStopsAtFirstMissingItem(@TempDir Path tempDir) throws Exception { + // item3 declared but item2 missing → loop stops after item1. + writeProps(tempDir, "name=Bluetooth\n" + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=First\n" + + "item3=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label3=Third (unreachable)\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertEquals(1, hooks.size(), + "loop must stop at the first missing itemN — item3 is unreachable past missing item2"); + assertEquals("First", hooks.get(0).getLabel()); } @Test void apiOnlyHookHasNullLabelButIsCallable(@TempDir Path tempDir) throws Exception { - // Label-less item: registered with the executor, hidden from the menu. + // item1 has no label1: registered with the executor, hidden from menu. writeProps(tempDir, "name=Bluetooth\n" + "namespace=bt\n" - + "item1.id=script\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); - - SimulatorHookLoaderTestFixture.resetCounters(); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); assertEquals(1, hooks.size()); assertFalse(hooks.get(0).hasMenuLabel(), "label-less item must be hidden from menu"); assertNull(hooks.get(0).getLabel()); - // ...and via the cross-platform entry point: - assertTrue(SimulatorHookExecutor.execute("bt:script")); + assertTrue(SimulatorHookExecutor.execute("bt:item1")); } @Test void executorReceivesEveryRegisteredHook(@TempDir Path tempDir) throws Exception { writeProps(tempDir, "name=Bluetooth\n" + "namespace=bt\n" - + "item1.id=alpha\n" - + "item1.label=Alpha\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" - + "item2.id=beta\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Alpha\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); SimulatorHookLoader.load(classloaderFor(tempDir)); - assertTrue(SimulatorHookExecutor.isRegistered("bt:alpha")); - assertTrue(SimulatorHookExecutor.isRegistered("bt:beta")); - assertFalse(SimulatorHookExecutor.isRegistered("bt:unknown")); - assertFalse(SimulatorHookExecutor.execute("bt:unknown"), + assertTrue(SimulatorHookExecutor.isRegistered("bt:item1")); + assertTrue(SimulatorHookExecutor.isRegistered("bt:item2")); + assertFalse(SimulatorHookExecutor.isRegistered("bt:item3")); + assertFalse(SimulatorHookExecutor.execute("bt:item3"), "execute() must return false for unknown ids without throwing"); } @Test void skipsFileWithoutName(@TempDir Path tempDir) throws Exception { - writeProps(tempDir, "item1.label=Orphan\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + writeProps(tempDir, "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Orphan\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); @@ -151,25 +165,14 @@ void skipsFileWithoutName(@TempDir Path tempDir) throws Exception { } @Test - void skipsItemWithoutMatchingAction(@TempDir Path tempDir) throws Exception { - writeProps(tempDir, "name=Bluetooth\n" - + "item1.label=Dangling\n" - + "item2.label=Beta\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#beta\n"); - - List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); - - assertEquals(1, hooks.size()); - assertEquals("Beta", hooks.get(0).getLabel()); - } - - @Test - void skipsUnknownClassButKeepsRest(@TempDir Path tempDir) throws Exception { + void skipsUnknownClassButContinuesScan(@TempDir Path tempDir) throws Exception { + // item1 fails to resolve → still proceeds to item2 (since item1 was + // declared, the loop continues; the failed lookup just yields no hook). writeProps(tempDir, "name=Bluetooth\n" - + "item1.label=Missing\n" - + "item1.action=com.example.DoesNotExist#nope\n" - + "item2.label=Alpha\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item1=com.example.DoesNotExist#nope\n" + + "label1=Missing\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label2=Alpha\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); @@ -180,10 +183,10 @@ void skipsUnknownClassButKeepsRest(@TempDir Path tempDir) throws Exception { @Test void skipsNonStaticMethod(@TempDir Path tempDir) throws Exception { writeProps(tempDir, "name=Bluetooth\n" - + "item1.label=Instance\n" - + "item1.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#instanceOnly\n" - + "item2.label=Alpha\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item1=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#instanceOnly\n" + + "label1=Instance\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label2=Alpha\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); @@ -193,12 +196,12 @@ void skipsNonStaticMethod(@TempDir Path tempDir) throws Exception { @Test void skipsMalformedActionString(@TempDir Path tempDir) throws Exception { - // No '#' separator at all. + // No '#' separator in item1. writeProps(tempDir, "name=Bluetooth\n" - + "item1.label=Bad\n" - + "item1.action=not_a_method_reference\n" - + "item2.label=Alpha\n" - + "item2.action=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n"); + + "item1=not_a_method_reference\n" + + "label1=Bad\n" + + "item2=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label2=Alpha\n"); List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); From 84e0e1cdebbee284538450f90c815f836fc87b14 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 16:41:22 +0300 Subject: [PATCH 07/11] ASCII-only sources + Vale-compliant docs The Android port's javac uses ASCII encoding (see feedback memory), so any non-ASCII character in a .java file under CodenameOne/ or Ports/JavaSE/ trips "unmappable character" errors in build-test (8/17). The redesign sneaked em-dashes ('--'), arrows ('->') and a right single quote into a handful of new/edited files. Sweep them out: every modified .java is now pure ASCII. Also drops a stray "e.g." from the dev guide section that Vale's Microsoft.Foreign rule flags; replaced with "for example". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 23 ++++++++++--------- CodenameOne/src/com/codename1/ui/CN.java | 2 +- .../impl/javase/simulator/SimulatorHook.java | 4 ++-- .../javase/simulator/SimulatorHookLoader.java | 4 ++-- .../Maven-Creating-CN1Libs.adoc | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 3f7b3ce15e..9265113ab2 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -32,20 +32,21 @@ /// /// The simulator scans cn1libs (and the app itself) for /// `META-INF/codenameone/simulator-hooks.properties` files; each hook -/// declared there is registered here under `namespace:id` keys. Tests -/// and tooling that live in the cross-platform `common/` project can -/// invoke a hook by id through [com.codename1.ui.CN#executeHook(String)] -/// (which forwards to [#execute(String)]) without referencing any -/// JavaSE-specific class. +/// declared there is registered here under `namespace:itemN` keys. The +/// JavaSE port hooks into [com.codename1.ui.CN#execute(String)] / +/// [com.codename1.ui.CN#canExecute(String)] so cross-platform code (such +/// as a CN1 UnitTest under `common/`) can invoke a hook via the same +/// `execute` it would use for any other URL, with no JavaSE-only import. /// /// On Android, iOS, JavaScript and other production targets this registry -/// is always empty, so `execute` returns `false` — that's the -/// "running outside a simulator" signal. +/// is always empty, so `execute` returns `false` and `isRegistered` is +/// always `false` -- the "running outside a simulator" signal. /// /// Hooks can be menu-backed (shipped with a label and visible in the -/// simulator's menu bar) or API-only (no label, callable by id only). Tests -/// that want behavioral coverage of cn1lib internals lean on the API-only -/// form so the menu UX stays focused on actions a human would click. +/// simulator's menu bar) or API-only (no label, callable by URL only). +/// Tests that want behavioral coverage of cn1lib internals lean on the +/// API-only form so the menu UX stays focused on actions a human would +/// click. public final class SimulatorHookExecutor { private static volatile Map hooks = Collections.emptyMap(); @@ -83,7 +84,7 @@ public static boolean isRegistered(String hookId) { } /// Diagnostic view of every registered id. Returns an unmodifiable - /// snapshot — never null. Intended for tests/inspectors; ordinary app + /// snapshot -- never null. Intended for tests/inspectors; ordinary app /// code shouldn't need this. public static Collection registeredIds() { return Collections.unmodifiableCollection(hooks.keySet()); diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index 77bdc10ba8..fc31961eee 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -686,7 +686,7 @@ public static Boolean canExecute(String url) { /// instead of being handed to the native URL opener. On Android, iOS, /// JavaScript and other production targets no hooks are ever registered, /// so a hook-style URL falls through to the normal native execute and - /// (almost always) becomes a no-op — tests should guard with + /// (almost always) becomes a no-op -- tests should guard with /// [#canExecute(String)] when running cross-platform. /// /// ```java diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java index fa4355460b..b2dda80d24 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -29,7 +29,7 @@ *

Hooks are positional within each {@code simulator-hooks.properties} * file: {@code item1} is the first menu entry, {@code item2} the second, * etc. A hook with a non-empty {@link #getLabel() label} renders as a menu - * item; a label-less hook is API-only — invisible in the menu but still + * item; a label-less hook is API-only -- invisible in the menu but still * callable via {@code CN.execute("namespace:itemN")} for test scaffolding.

*/ public final class SimulatorHook { @@ -53,7 +53,7 @@ public SimulatorHook(String namespace, int index, String menuName, String label, /** 1-based position of this item within its properties file. */ public int getIndex() { return index; } - /** URL passed to {@code CN.execute} to trigger this hook — {@code namespace + ":item" + index}. */ + /** URL passed to {@code CN.execute} to trigger this hook -- {@code namespace + ":item" + index}. */ public String getExecutorKey() { return namespace + ":item" + index; } /** Display title of the menu this hook belongs to (one per properties file). */ diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index 21eaef184d..fafe0697a0 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -54,7 +54,7 @@ * item2=com.example.bt.sim.Hooks#addDemoPeripheral * label2=Add demo peripheral * - * # Label omitted → API-only hook. Callable from tests via + * # Label omitted -> API-only hook. Callable from tests via * # CN.execute("bluetooth:item3"), invisible in the menu. * item3=com.example.bt.sim.Hooks#primeReadFailure * @@ -237,7 +237,7 @@ private static Runnable buildInvoker(ClassLoader cl, String action, URL source, @Override public void run() { // callSeriallyAndWait so off-EDT callers (every CN1 UnitTest's - // runTest()) block until the hook completes — tests would + // runTest()) block until the hook completes -- tests would // otherwise assert state changes before the EDT got to run // the action. On the EDT, callSeriallyAndWait runs the body // inline without re-entering the dispatch queue. diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index c35820ffa7..c22de40fa3 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -489,7 +489,7 @@ Required keys: Optional keys: -`namespace`:: Identifier used for the `CN.execute` lookup, e.g. `bluetooth` for a URL like `bluetooth:item1`. Defaults to lowercased, ASCII-slugified `name` (`Push Notifications!` → `push-notifications`). Set this explicitly when you want a different identifier from the display name. +`namespace`:: Identifier used for the `CN.execute` lookup (for example, `bluetooth` for a URL like `bluetooth:item1`). Defaults to lowercased, ASCII-slugified `name` (`Push Notifications!` → `push-notifications`). Set this explicitly when you want a different identifier from the display name. `labelN`:: Display text for the matching `itemN`. Omit entirely to make the hook API-only — registered with `CN.execute` but hidden from the menu. No groups, no submenus, no priority — flat by design. If you need ordering relative to another cn1lib, you can't have it: discovery order wins, and that's intentional so the contract stays small and the future simulator UX can re-render this metadata however it likes. From 298963e8bc4616914664ebe9dcba7bf230527e56 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 18:59:53 +0300 Subject: [PATCH 08/11] Replace volatile field with AtomicReference PMD's AvoidUsingVolatile rule (enforced by the build-test JDK 8 "Generate static analysis HTML summaries" step) flagged the `private static volatile Map hooks` field. The semantics we want -- safe replacement of the registry from the JavaSE port while readers see either the old or new map atomically -- map cleanly to AtomicReference, which avoids the volatile keyword and keeps PMD happy. No observable behavior change. 12 framework JUnit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../codename1/system/SimulatorHookExecutor.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 9265113ab2..2074bb585e 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -27,6 +27,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; /// Cross-platform registry of named actions that the JavaSE simulator exposes. /// @@ -49,7 +50,11 @@ /// click. public final class SimulatorHookExecutor { - private static volatile Map hooks = Collections.emptyMap(); + // AtomicReference rather than a `volatile` field: same single-writer / + // many-reader visibility, but PMD's AvoidUsingVolatile rule (part of + // the JDK 8 PR CI gate) treats raw volatile as a code smell. + private static final AtomicReference> HOOKS = + new AtomicReference>(Collections.emptyMap()); private SimulatorHookExecutor() {} @@ -68,7 +73,7 @@ public static boolean execute(String hookId) { if (hookId == null) { return false; } - Runnable r = hooks.get(hookId); + Runnable r = HOOKS.get().get(hookId); if (r == null) { return false; } @@ -80,14 +85,14 @@ public static boolean execute(String hookId) { /// Useful for tests that want to skip themselves gracefully when running /// on a platform that doesn't expose the relevant cn1lib hook. public static boolean isRegistered(String hookId) { - return hookId != null && hooks.containsKey(hookId); + return hookId != null && HOOKS.get().containsKey(hookId); } /// Diagnostic view of every registered id. Returns an unmodifiable /// snapshot -- never null. Intended for tests/inspectors; ordinary app /// code shouldn't need this. public static Collection registeredIds() { - return Collections.unmodifiableCollection(hooks.keySet()); + return Collections.unmodifiableCollection(HOOKS.get().keySet()); } /// Replaces the entire registry. The JavaSE port calls this every time @@ -95,9 +100,9 @@ public static Collection registeredIds() { /// targets nothing calls it and the registry stays empty. public static void register(Map registered) { if (registered == null || registered.isEmpty()) { - hooks = Collections.emptyMap(); + HOOKS.set(Collections.emptyMap()); return; } - hooks = Collections.unmodifiableMap(new HashMap(registered)); + HOOKS.set(Collections.unmodifiableMap(new HashMap(registered))); } } From a7c03d9079da980446f92cd19059bc919416caaf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 20 May 2026 19:10:08 +0300 Subject: [PATCH 09/11] SimulatorHookExecutor: synchronized accessors (CLDC has no AtomicReference) The volatile-vs-AtomicReference choice was a false binary: - volatile: trips PMD's AvoidUsingVolatile gate on the JDK 8 PR CI run. - AtomicReference: triggers "package java.util.concurrent.atomic does not exist" in the JavaSE port's javase-simulator-tests Ant step, which compiles the framework core against the CLDC subset. CLDC doesn't ship j.u.c.atomic. Both gates fail on the new class. The portable third option is plain synchronized accessors guarding the registry field with a private lock object -- same memory-visibility guarantee, compiles under every supported target, no PMD warning. Hook actions still run OUTSIDE the lock so a long-running hook can't deadlock a concurrent register() call. 12 framework JUnit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 2074bb585e..4fbde53047 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -27,7 +27,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; /// Cross-platform registry of named actions that the JavaSE simulator exposes. /// @@ -50,14 +49,23 @@ /// click. public final class SimulatorHookExecutor { - // AtomicReference rather than a `volatile` field: same single-writer / - // many-reader visibility, but PMD's AvoidUsingVolatile rule (part of - // the JDK 8 PR CI gate) treats raw volatile as a code smell. - private static final AtomicReference> HOOKS = - new AtomicReference>(Collections.emptyMap()); + // Plain field guarded by an internal lock. AtomicReference would be the + // natural fit but CLDC (which the core targets) doesn't ship + // java.util.concurrent.atomic, and `volatile` trips PMD's + // AvoidUsingVolatile rule on the JDK 8 PR CI gate. Synchronized + // accessors give the same memory-visibility guarantees and compile + // under every supported target. + private static final Object LOCK = new Object(); + private static Map hooks = Collections.emptyMap(); private SimulatorHookExecutor() {} + private static Map snapshot() { + synchronized (LOCK) { + return hooks; + } + } + /// Invokes the action registered under `hookId`. Returns `true` /// if a hook with that id was found and dispatched, `false` /// otherwise. Invocation is delegated to whatever the registering code @@ -73,7 +81,10 @@ public static boolean execute(String hookId) { if (hookId == null) { return false; } - Runnable r = HOOKS.get().get(hookId); + // Resolve the action under the lock, then call run() outside it + // -- hook actions can be long-running and may call back into + // register() (e.g., a "reload simulator menus" hook). + Runnable r = snapshot().get(hookId); if (r == null) { return false; } @@ -85,24 +96,28 @@ public static boolean execute(String hookId) { /// Useful for tests that want to skip themselves gracefully when running /// on a platform that doesn't expose the relevant cn1lib hook. public static boolean isRegistered(String hookId) { - return hookId != null && HOOKS.get().containsKey(hookId); + return hookId != null && snapshot().containsKey(hookId); } /// Diagnostic view of every registered id. Returns an unmodifiable /// snapshot -- never null. Intended for tests/inspectors; ordinary app /// code shouldn't need this. public static Collection registeredIds() { - return Collections.unmodifiableCollection(HOOKS.get().keySet()); + return Collections.unmodifiableCollection(snapshot().keySet()); } /// Replaces the entire registry. The JavaSE port calls this every time /// it rebuilds the simulator menu (e.g., after a reload). On non-simulator /// targets nothing calls it and the registry stays empty. public static void register(Map registered) { + Map next; if (registered == null || registered.isEmpty()) { - HOOKS.set(Collections.emptyMap()); - return; + next = Collections.emptyMap(); + } else { + next = Collections.unmodifiableMap(new HashMap(registered)); + } + synchronized (LOCK) { + hooks = next; } - HOOKS.set(Collections.unmodifiableMap(new HashMap(registered))); } } From ce081aae901282e24dd391d7f094ed25f764e0d4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 07:09:44 +0300 Subject: [PATCH 10/11] Drop bogus Oracle header from new hook files; move docs to Display#execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new SimulatorHookExecutor / SimulatorHook / SimulatorHookLoader files were authored for Codename One, not imported from Oracle, so the Oracle/Classpath header that older OpenJDK-derived files carry doesn't belong on them. Strip the header — recent CN1-authored files ship without one. Also revert the doc additions to CN.execute and fold them into Display.execute, which is where the URL execute API actually lives and where this behavior should be documented. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 23 ------------------- CodenameOne/src/com/codename1/ui/CN.java | 16 +------------ CodenameOne/src/com/codename1/ui/Display.java | 23 ++++++++++++++++++- .../impl/javase/simulator/SimulatorHook.java | 23 ------------------- .../javase/simulator/SimulatorHookLoader.java | 23 ------------------- 5 files changed, 23 insertions(+), 85 deletions(-) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 4fbde53047..833f187300 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -1,26 +1,3 @@ -/* - * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores - * CA 94065 USA or visit www.oracle.com if you need additional information or - * have any questions. - */ package com.codename1.system; import java.util.Collection; diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index fc31961eee..62470ab8da 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -678,16 +678,7 @@ public static Boolean canExecute(String url) { return Display.impl.canExecute(url); } - /// Executes the given URL on the native platform. Also serves as the - /// cross-platform entry point for the JavaSE simulator's hook system: - /// the simulator scans cn1libs for `META-INF/codenameone/simulator-hooks.properties`, - /// and a URL of the form `namespace:itemN` that matches a registered hook - /// is intercepted by the JavaSE port and dispatched on the CN1 EDT - /// instead of being handed to the native URL opener. On Android, iOS, - /// JavaScript and other production targets no hooks are ever registered, - /// so a hook-style URL falls through to the normal native execute and - /// (almost always) becomes a no-op -- tests should guard with - /// [#canExecute(String)] when running cross-platform. + /// Executes the given URL on the native platform /// /// ```java /// Boolean can = Display.getInstance().canExecute("imdb:///find?q=godfather"); @@ -696,11 +687,6 @@ public static Boolean canExecute(String url) { /// } else { /// Display.getInstance().execute("http://www.imdb.com"); /// } - /// - /// // Driving a cn1lib's simulator hook from a CN1 UnitTest: - /// if (Boolean.TRUE.equals(CN.canExecute("bluetooth:item1"))) { - /// CN.execute("bluetooth:item1"); // toggle the simulated adapter - /// } /// ``` /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index e9e08943f5..4789e7a109 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -3734,7 +3734,7 @@ public Boolean canExecute(String url) { return impl.canExecute(url); } - /// Executes the given URL on the native platform + /// Executes the given URL on the native platform. /// /// ```java /// Boolean can = Display.getInstance().canExecute("imdb:///find?q=godfather"); @@ -3745,6 +3745,27 @@ public Boolean canExecute(String url) { /// } /// ``` /// + /// On the JavaSE simulator this method also serves as the cross-platform + /// entry point for the simulator hook system. The simulator scans cn1libs + /// (and the running app) for `META-INF/codenameone/simulator-hooks.properties` + /// files, and a URL of the form `namespace:itemN` that matches a registered + /// hook is intercepted and dispatched on the CN1 EDT instead of being + /// handed to the native URL opener. On Android, iOS, JavaScript and other + /// production targets no hooks are ever registered, so a hook-style URL + /// falls through to the normal native execute and (almost always) becomes + /// a no-op. CN1 UnitTests running cross-platform should guard with + /// [#canExecute(String)] before invoking a hook URL: + /// + /// ```java + /// if (Boolean.TRUE.equals(Display.getInstance().canExecute("bluetooth:item1"))) { + /// Display.getInstance().execute("bluetooth:item1"); // toggle the simulated adapter + /// } + /// ``` + /// + /// See the developer guide's "Creating CN1Libs" chapter for the + /// `simulator-hooks.properties` format and the positional `itemN` / `labelN` + /// conventions. + /// /// #### Parameters /// /// - `url`: the url to execute diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java index b2dda80d24..cbb35eceb4 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -1,26 +1,3 @@ -/* - * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores - * CA 94065 USA or visit www.oracle.com if you need additional information or - * have any questions. - */ package com.codename1.impl.javase.simulator; /** diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index fafe0697a0..56c8d108a4 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -1,26 +1,3 @@ -/* - * Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores - * CA 94065 USA or visit www.oracle.com if you need additional information or - * have any questions. - */ package com.codename1.impl.javase.simulator; import com.codename1.system.SimulatorHookExecutor; From df1baac5ea6d7a295dd0536ad3a8664c8e1066db Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 21 May 2026 07:16:01 +0300 Subject: [PATCH 11/11] Add Codename One license header to new hook files Every CN1-authored source file in this repo carries the GPL v2 + Classpath header. The previous commit stripped the bogus Oracle attribution but left the new files headerless; this restores the proper Codename One header. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../system/SimulatorHookExecutor.java | 22 +++++++++++++++++++ .../impl/javase/simulator/SimulatorHook.java | 22 +++++++++++++++++++ .../javase/simulator/SimulatorHookLoader.java | 22 +++++++++++++++++++ .../simulator/SimulatorHookLoaderTest.java | 22 +++++++++++++++++++ .../SimulatorHookLoaderTestFixture.java | 22 +++++++++++++++++++ 5 files changed, 110 insertions(+) diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java index 833f187300..c24fbda697 100644 --- a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.system; import java.util.Collection; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java index cbb35eceb4..6ef45bef69 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.javase.simulator; /** diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java index 56c8d108a4..7f6e682874 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.javase.simulator; import com.codename1.system.SimulatorHookExecutor; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java index 9e46916c1b..038e2284b2 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.javase.simulator; import com.codename1.system.SimulatorHookExecutor; diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java index ee6a69e9ce..72f7926367 100644 --- a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ package com.codename1.impl.javase.simulator; /**