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
Original file line number Diff line number Diff line change
Expand Up @@ -556,12 +556,30 @@ private List<File> generateNativeInterfaceImpls(File buildDir, List<Class<?>> na
private File writeNativeInterfaceImpl(File genDir, Class<?> iface) throws IOException {
String pkg = iface.getPackage() != null ? iface.getPackage().getName() : "";
String simpleImpl = iface.getSimpleName() + "Impl";
String registryKey = iface.getName().replace('.', '_');

File pkgDir = pkg.isEmpty() ? genDir : new File(genDir, pkg.replace('.', File.separatorChar));
pkgDir.mkdirs();
File out = new File(pkgDir, simpleImpl + ".java");

PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(out), StandardCharsets.UTF_8));
try {
pw.print(nativeInterfaceImplSource(iface));
} finally {
pw.close();
}
return out;
}

/**
* Java source of the generated {@code <Interface>Impl} that binds every method of
* {@code iface} to the host bridge. Package visible so the binding contract can be
* asserted without running a build.
*/
static String nativeInterfaceImplSource(Class<?> iface) {
String pkg = iface.getPackage() != null ? iface.getPackage().getName() : "";
String simpleImpl = iface.getSimpleName() + "Impl";
String registryKey = iface.getName().replace('.', '_');

StringBuilder sb = new StringBuilder();
if (!pkg.isEmpty()) {
sb.append("package ").append(pkg).append(";\n\n");
Expand All @@ -577,21 +595,41 @@ private File writeNativeInterfaceImpl(File genDir, Class<?> iface) throws IOExce
appendNativeInterfaceImplMethod(sb, m);
}
sb.append("}\n");
return sb.toString();
}

PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(out), StandardCharsets.UTF_8));
try {
pw.print(sb.toString());
} finally {
pw.close();
}
return out;
/**
* {@code isSupported()} is the contract's own "is this available here?" question, so it
* must answer rather than throw. An Impl is generated and registered for EVERY native
* interface in the app, so {@code NativeLookup.create()} never returns null on this port
* and {@code isSupported()} is the only signal the developer has. Without the guard, an
* app whose bundle carries no JS implementation for the interface got the bridge's
* "No native interface implementation registered for ..." rejection thrown straight
* through the standard {@code create(X.class) != null && x.isSupported()} check.
*/
private static boolean isSupportedQuery(Method m) {
return "isSupported".equals(m.getName())
&& m.getParameterTypes().length == 0
&& m.getReturnType() == boolean.class;
}

private void appendNativeInterfaceImplMethod(StringBuilder sb, Method m) {
private static void appendNativeInterfaceImplMethod(StringBuilder sb, Method m) {
Class<?>[] params = m.getParameterTypes();
Class<?> ret = m.getReturnType();
String methodKey = nativeInterfaceMethodKey(m);

if (isSupportedQuery(m)) {
sb.append(" public boolean isSupported() {\n");
sb.append(" try {\n");
sb.append(" return com.codename1.impl.platform.js.NativeInterfaceBridge.callBoolean(__NI, \"")
.append(methodKey).append("\", new Object[0]);\n");
sb.append(" } catch (Throwable __t) {\n");
sb.append(" return false;\n");
sb.append(" }\n");
sb.append(" }\n\n");
return;
}

sb.append(" public ").append(ret.getCanonicalName()).append(" ").append(m.getName()).append("(");
for (int i = 0; i < params.length; i++) {
if (i > 0) sb.append(", ");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.builders;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class JavaScriptBuilderNativeInterfaceTest {

@Test
void isSupportedAnswersFalseInsteadOfThrowingWhenNoJsImplementationIsBound() {
// An Impl is generated and registered for EVERY native interface in the app, so
// NativeLookup.create() never returns null on the JavaScript port. That makes
// isSupported() the only signal the developer has, and an app with no JS stub for
// the interface used to get the bridge's "No native interface implementation
// registered" rejection thrown straight out of the standard
// "create(X.class) != null && x.isSupported()" guard (issue #5512).
String source = JavaScriptBuilder.nativeInterfaceImplSource(SampleJsNative.class);

assertTrue(source.contains("public boolean isSupported() {"),
"Generated impl should implement isSupported(). source=" + source);
assertTrue(source.contains("callBoolean(__NI, \"isSupported_\", new Object[0])"),
"isSupported() should still ask the host bridge first. source=" + source);
assertTrue(source.contains("} catch (Throwable __t) {") && source.contains("return false;"),
"isSupported() must degrade to false rather than propagate the bridge failure. source="
+ source);
}

@Test
void everyOtherMethodStillPropagatesBridgeFailures() {
// Swallowing failures anywhere else would turn a genuinely unimplemented native
// into a silent no-op, so the guard is scoped to isSupported() alone.
String source = JavaScriptBuilder.nativeInterfaceImplSource(SampleJsNative.class);

assertTrue(source.contains("return com.codename1.impl.platform.js.NativeInterfaceBridge"
+ ".callString(__NI, \"greet__java_lang_String\", new Object[]{ p0 });"),
"greet(String) should delegate straight to the bridge. source=" + source);
assertTrue(source.contains("com.codename1.impl.platform.js.NativeInterfaceBridge"
+ ".callVoid(__NI, \"ping__int\", new Object[]{ Integer.valueOf(p0) });"),
"ping(int) should delegate straight to the bridge. source=" + source);
assertEquals(1, countOccurrences(source, "catch (Throwable"),
"Only isSupported() may swallow bridge failures. source=" + source);
}

@Test
void bindsTheInterfaceUnderItsUnderscoredRegistryKey() {
String source = JavaScriptBuilder.nativeInterfaceImplSource(SampleJsNative.class);

assertTrue(source.contains("private static final String __NI = \""
+ SampleJsNative.class.getName().replace('.', '_') + "\";"),
"Impl should bind to the cn1_native_interfaces registry key. source=" + source);
}

private static int countOccurrences(String source, String needle) {
int count = 0;
for (int idx = source.indexOf(needle); idx >= 0; idx = source.indexOf(needle, idx + needle.length())) {
count++;
}
return count;
}
}

/** Stand-in for an app-supplied native interface; top level so the generated source is valid Java. */
interface SampleJsNative extends com.codename1.system.NativeInterface {
String greet(String name);

void ping(int count);
}
30 changes: 30 additions & 0 deletions vm/ByteCodeTranslator/src/javascript/browser_bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,45 @@
// registry the stub self-registers into, populated on the main thread by the
// <script>-loaded stub) and invoke it with the trailing callback, returning a
// Promise so the worker resumes with the result once callback.complete fires.
//
// isSupported() is the NativeInterface contract's own "is this available here?"
// question, so an unbound interface must make it ANSWER false rather than reject.
// The builder generates and registers an <Iface>Impl for EVERY native interface in
// the app, so NativeLookup.create() never returns null on this port and isSupported()
// is the only signal the developer has; rejecting turned the standard
// create(X.class) != null && x.isSupported()
// guard into a thrown RuntimeException for any app that shipped no JS stub for the
// interface (issue #5512). Every other method still rejects -- calling an
// unimplemented native is a genuine bug and must stay loud.
var NI_IS_SUPPORTED = 'isSupported_';
var niUnboundWarned = {};

function niUnsupported(iface, reason) {
if (!niUnboundWarned[iface]) {
niUnboundWarned[iface] = true;
if (global.console && global.console.warn) {
global.console.warn('Codename One: native interface ' + iface + ' is not supported in this build ('
+ reason + '); isSupported() answers false.');
}
}
return Promise.resolve(false);
}

function cn1InvokeNativeInterface(iface, method, args) {
var registry = global.cn1_native_interfaces
|| (global.window && global.window.cn1_native_interfaces);
var impl = registry ? registry[iface] : null;
if (!impl) {
if (method === NI_IS_SUPPORTED) {
return niUnsupported(iface, 'no JS implementation registered');
}
return Promise.reject(new Error('No native interface implementation registered for ' + iface));
}
var fn = impl[method];
if (typeof fn !== 'function') {
if (method === NI_IS_SUPPORTED) {
return niUnsupported(iface, 'the registered JS implementation defines no isSupported');
}
return Promise.reject(new Error('Native interface ' + iface + ' has no implementation for ' + method));
}
var callArgs = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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.tools.translator;

import org.junit.jupiter.api.Test;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Drives {@code browser_bridge.js}'s native-interface dispatch directly in node, with the
* page boot suppressed (a {@code document.readyState} of {@code "loading"} makes the bridge
* wait for a DOMContentLoaded that never fires), so the registry lookup contract can be
* exercised without a browser or a translated app.
*/
class JavaScriptNativeInterfaceBridgeTest {
private static final Path BROWSER_BRIDGE =
Paths.get("..", "ByteCodeTranslator", "src", "javascript", "browser_bridge.js");

@Test
void isSupportedAnswersFalseForAnInterfaceWithNoRegisteredJsImplementation() throws Exception {
// NativeLookup.create() always resolves on this port -- the builder generates and
// registers an <Iface>Impl for EVERY native interface in the app -- so isSupported()
// is the developer's only "is this bound here?" signal. Rejecting the call turned the
// standard "create(X.class) != null && x.isSupported()" guard into a thrown
// RuntimeException for any app shipping no JS stub for the interface (issue #5512).
String out = runBridgeProbe();

assertTrue(out.contains("unregistered.isSupported=false"),
"isSupported() on an unbound interface must resolve false, not reject. out=" + out);
assertTrue(out.contains("partial.isSupported=false"),
"isSupported() must resolve false when the registered stub omits it. out=" + out);
}

@Test
void aRegisteredImplementationStillAnswersForItself() throws Exception {
String out = runBridgeProbe();

assertTrue(out.contains("registered.isSupported=true"),
"A registered stub's own isSupported() answer must win. out=" + out);
}

@Test
void everyOtherMethodOfAnUnboundInterfaceStillFails() throws Exception {
// Silently resolving an unimplemented native would turn a real binding bug into a
// no-op, so the fallback is scoped to isSupported() alone.
String out = runBridgeProbe();

assertTrue(out.contains("unregistered.other=rejected:No native interface implementation registered for"),
"Calling an unimplemented native must still fail loudly. out=" + out);
}

private static String runBridgeProbe() throws Exception {
Path harness = Files.createTempFile("js-native-interface-bridge", ".js");
Files.write(harness, probeSource().getBytes(StandardCharsets.UTF_8));

Process process = new ProcessBuilder("node", harness.toString()).start();
String stdout = readAll(process.getInputStream());
String stderr = readAll(process.getErrorStream());
int rc = process.waitFor();
assertEquals(0, rc, "Node bridge probe should exit cleanly. stdout: " + stdout + " stderr: " + stderr);
return stdout;
}

private static String probeSource() throws Exception {
String bridgePath = BROWSER_BRIDGE.toAbsolutePath().normalize().toString().replace("\\", "\\\\");
return "const fs = require('fs');\n"
+ "const vm = require('vm');\n"
+ "const src = fs.readFileSync('" + bridgePath + "', 'utf8');\n"
+ "const documentStub = {\n"
+ " readyState: 'loading',\n"
+ " addEventListener: function() {},\n"
+ " getElementById: function() { return null; },\n"
+ " createElement: function() { return { style: {}, appendChild: function() {} }; },\n"
+ " head: { appendChild: function() {} }\n"
+ "};\n"
+ "const selfStub = {\n"
+ " console: { log: function() {}, warn: function() {}, error: function() {} },\n"
+ " location: { search: '', href: 'http://localhost/' },\n"
+ " devicePixelRatio: 1,\n"
+ " addEventListener: function() {},\n"
+ " setTimeout: setTimeout,\n"
+ " clearTimeout: clearTimeout,\n"
+ " Promise: Promise\n"
+ "};\n"
+ "selfStub.self = selfStub;\n"
+ "selfStub.window = selfStub;\n"
+ "selfStub.document = documentStub;\n"
+ "vm.runInContext(src, vm.createContext(selfStub), { filename: 'browser_bridge.js' });\n"
+ "const call = selfStub.cn1HostBridge.handlers['__cn1_native_interface_call__'];\n"
+ "async function probe(label, iface, method) {\n"
+ " try {\n"
+ " console.log(label + '=' + await call(iface, method, []));\n"
+ " } catch (err) {\n"
+ " console.log(label + '=rejected:' + String(err && err.message || err));\n"
+ " }\n"
+ "}\n"
+ "async function run() {\n"
+ " await probe('unregistered.isSupported', 'bridge_SystemTime', 'isSupported_');\n"
+ " await probe('unregistered.other', 'bridge_SystemTime', 'currentTime_');\n"
+ " selfStub.cn1_native_interfaces = {\n"
+ " bridge_Partial: { other_: function(cb) { cb.complete(1); } },\n"
+ " bridge_Real: { isSupported_: function(cb) { cb.complete(true); } }\n"
+ " };\n"
+ " await probe('partial.isSupported', 'bridge_Partial', 'isSupported_');\n"
+ " await probe('registered.isSupported', 'bridge_Real', 'isSupported_');\n"
+ "}\n"
+ "run();\n";
}

private static String readAll(InputStream in) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int read;
while ((read = in.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
}
Loading