diff --git a/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java new file mode 100644 index 0000000000..c24fbda697 --- /dev/null +++ b/CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java @@ -0,0 +1,122 @@ +/* + * 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; +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 +/// `META-INF/codenameone/simulator-hooks.properties` files; each hook +/// 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` 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 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 { + + // 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 + /// 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; + } + // 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; + } + r.run(); + return true; + } + + /// 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 && 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(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()) { + next = Collections.emptyMap(); + } else { + next = Collections.unmodifiableMap(new HashMap(registered)); + } + synchronized (LOCK) { + hooks = next; + } + } +} 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/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index f4267c7eb7..3b52984361 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -3784,6 +3784,41 @@ 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) { + // 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()); + 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 +5060,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); } @@ -10283,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; @@ -10298,7 +10345,7 @@ public void run() { launchBrowserThatWorks(fUrl); } }); - + } catch (Exception ex) { ex.printStackTrace(); } @@ -14817,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 new file mode 100644 index 0000000000..6ef45bef69 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHook.java @@ -0,0 +1,75 @@ +/* + * 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; + +/** + * One positional action contributed by a cn1lib (or the app) to the simulator. + * + *

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 int index; + private final String menuName; + private final String label; + private final Runnable invoke; + + public SimulatorHook(String namespace, int index, String menuName, String label, Runnable invoke) { + this.namespace = namespace; + this.index = index; + this.menuName = menuName; + this.label = label; + this.invoke = invoke; + } + + /** Stable namespace token (one per properties file). */ + public String getNamespace() { return namespace; } + + /** 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}. */ + 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.execute} + * 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 new file mode 100644 index 0000000000..7f6e682874 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/simulator/SimulatorHookLoader.java @@ -0,0 +1,257 @@ +/* + * 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; +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 file + * declares one named group of positional items: + * + *
+ * name=Bluetooth
+ * namespace=bluetooth          # optional; defaults to slugified `name`
+ *
+ * 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.execute("bluetooth:item3"), invisible in the menu.
+ * item3=com.example.bt.sim.Hooks#primeReadFailure
+ * 
+ * + *

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.

+ * + *

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 { + + 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 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(); + 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(); + SimulatorHookExecutor.register(Collections.emptyMap()); + 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(); + } + } + // 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()); + } + SimulatorHookExecutor.register(registered); + return out; + } + + private static void loadOne(URL url, ClassLoader cl, List out) throws IOException { + Properties props = new Properties(); + 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(); + String namespace = props.getProperty("namespace"); + if (namespace == null || namespace.trim().length() == 0) { + namespace = slugify(menuName); + } else { + namespace = namespace.trim(); + } + + // 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) { + break; + } + String label = props.getProperty("label" + index); + if (label != null) { + label = label.trim(); + if (label.length() == 0) { + label = null; + } + } + Runnable invoke = buildInvoker(cl, action.trim(), url, index); + if (invoke != null) { + out.add(new SimulatorHook(namespace, index, menuName, label, invoke)); + } + index++; + } + } + + /** + * 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; + } + } + 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, int index) { + int hash = action.indexOf('#'); + if (hash <= 0 || hash == action.length() - 1) { + System.err.println("SimulatorHookLoader: " + source + " item" + index + + " 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 + " item" + index + + " references unknown class '" + fqcn + "'"); + return null; + } + try { + method = targetClass.getDeclaredMethod(methodName, new Class[0]); + } catch (NoSuchMethodException ex) { + 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 + " item" + index + + " references non-static method '" + fqcn + "#" + methodName + "'"); + return null; + } + method.setAccessible(true); + final URL src = source; + return new Runnable() { + @Override + public void run() { + // 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() { + try { + method.invoke(null); + } catch (Throwable t) { + System.err.println("SimulatorHookLoader: action from " + src + " threw"); + t.printStackTrace(); + } + } + }); + } + }; + } +} diff --git a/docs/developer-guide/Maven-Creating-CN1Libs.adoc b/docs/developer-guide/Maven-Creating-CN1Libs.adoc index aaa48d5b1d..c22de40fa3 100644 --- a/docs/developer-guide/Maven-Creating-CN1Libs.adoc +++ b/docs/developer-guide/Maven-Creating-CN1Libs.adoc @@ -453,6 +453,130 @@ 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 (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. + +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 + +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 +namespace=bluetooth # optional; defaults to slugified `name` + +# 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 + +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`:: 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`:: 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. + +==== The action method + +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] +---- +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 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. + +Its `simulator-hooks.properties` looks like this: + +[source,properties] +---- +name=Bluetooth +namespace=bluetooth + +item1=com.codename1.bluetoothle.BluetoothSimulatorHooks#toggleAdapter +label1=Toggle adapter on/off + +item2=com.codename1.bluetoothle.BluetoothSimulatorHooks#addDemoPeripheral +label2=Add demo peripheral + +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=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. `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. 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] +---- +import com.codename1.testing.AbstractTest; +import com.codename1.ui.CN; + +public class BluetoothDemoTest extends AbstractTest { + @Override + public boolean runTest() throws Exception { + // 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; + } +} +---- + +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 + +* *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. + === 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/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 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..038e2284b2 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTest.java @@ -0,0 +1,269 @@ +/* + * 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; +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.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 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=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)); + + assertEquals(2, hooks.size()); + SimulatorHook first = hooks.get(0); + assertEquals("Bluetooth", first.getMenuName()); + assertEquals("bluetooth", first.getNamespace(), "namespace should default to slugified name"); + 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 honorsExplicitNamespace(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\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("bt:item1", hooks.get(0).getExecutorKey()); + } + + @Test + void slugifiesMultiWordName(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Push Notifications!\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 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=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("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 { + // item1 has no label1: registered with the executor, hidden from menu. + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\n" + + "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()); + assertTrue(SimulatorHookExecutor.execute("bt:item1")); + } + + @Test + void executorReceivesEveryRegisteredHook(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\n" + + "namespace=bt\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: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=com.codename1.impl.javase.simulator.SimulatorHookLoaderTestFixture#alpha\n" + + "label1=Orphan\n"); + + List hooks = SimulatorHookLoader.load(classloaderFor(tempDir)); + + assertTrue(hooks.isEmpty(), "expected zero hooks but got: " + hooks); + } + + @Test + 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=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)); + + assertEquals(1, hooks.size()); + assertEquals("Alpha", hooks.get(0).getLabel()); + } + + @Test + void skipsNonStaticMethod(@TempDir Path tempDir) throws Exception { + writeProps(tempDir, "name=Bluetooth\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)); + + assertEquals(1, hooks.size()); + assertEquals("Alpha", hooks.get(0).getLabel()); + } + + @Test + void skipsMalformedActionString(@TempDir Path tempDir) throws Exception { + // No '#' separator in item1. + writeProps(tempDir, "name=Bluetooth\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)); + + assertEquals(1, hooks.size()); + 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"); + 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..72f7926367 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/SimulatorHookLoaderTestFixture.java @@ -0,0 +1,53 @@ +/* + * 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; + +/** + * 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; + } +}