Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions CodenameOne/src/com/codename1/system/SimulatorHookExecutor.java
Original file line number Diff line number Diff line change
@@ -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<String, Runnable> hooks = Collections.emptyMap();

private SimulatorHookExecutor() {}

private static Map<String, Runnable> 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<String> 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<String, Runnable> registered) {
Map<String, Runnable> next;
if (registered == null || registered.isEmpty()) {
next = Collections.emptyMap();
} else {
next = Collections.unmodifiableMap(new HashMap<String, Runnable>(registered));
}
synchronized (LOCK) {
hooks = next;
}
}
}
23 changes: 22 additions & 1 deletion CodenameOne/src/com/codename1/ui/Display.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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
Expand Down
58 changes: 56 additions & 2 deletions Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<JMenu> buildExtensionMenus() {
List<SimulatorHook> hooks = SimulatorHookLoader.load();
LinkedHashMap<String, JMenu> byName = new LinkedHashMap<String, JMenu>();
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<JMenu>(byName.values());
}

private static Component findStatusBarComponent(Form f) {
if (f == null || f.getToolbar() == null) {
return null;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -10298,7 +10345,7 @@ public void run() {
launchBrowserThatWorks(fUrl);
}
});

} catch (Exception ex) {
ex.printStackTrace();
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
Loading
Loading