From 6e1d3838d1c23b9b5f55299ad11cfe758150ce4a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:39:12 +0700 Subject: [PATCH] JS port: isSupported() must answer false for an unbound native interface NativeLookup.create() can never return null on the JavaScript port -- the builder generates and registers an Impl for EVERY NativeInterface it finds in the app, whether or not a JS implementation ships with the bundle. That makes isSupported() the developer's only "is this bound here?" signal, and it was throwing rather than answering: the host bridge rejected the call with "No native interface implementation registered for ", which surfaced in the worker as a RuntimeException out of the standard NativeLookup.create(X.class) != null && x.isSupported() guard. On iOS/Android the same guard works because a missing native impl means no *Impl class at all, so create() returns null. Two coordinated changes: * browser_bridge.js resolves false for the isSupported_ key when the interface is absent from cn1_native_interfaces, or when the registered stub defines no isSupported (warning once per interface). Every other method still rejects -- calling an unimplemented native is a genuine bug and must stay loud. * The generated Impl wraps its isSupported() bridge call in a try/catch that degrades to false, so the contract holds even if the call fails for some other reason. Tests: a node harness drives browser_bridge.js's dispatch directly with the page boot suppressed, and the generated-impl source is asserted without running a build. Fixes #5512 Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/builders/JavaScriptBuilder.java | 56 +++++-- .../JavaScriptBuilderNativeInterfaceTest.java | 90 +++++++++++ .../src/javascript/browser_bridge.js | 30 ++++ .../JavaScriptNativeInterfaceBridgeTest.java | 148 ++++++++++++++++++ 4 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java create mode 100644 vm/tests/src/test/java/com/codename1/tools/translator/JavaScriptNativeInterfaceBridgeTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java index d8bec442dd3..67679cfd92f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/JavaScriptBuilder.java @@ -556,12 +556,30 @@ private List generateNativeInterfaceImpls(File buildDir, List> 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 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"); @@ -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(", "); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java new file mode 100644 index 00000000000..359eff78928 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/JavaScriptBuilderNativeInterfaceTest.java @@ -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); +} diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 8ab3d4e313e..d76800d183b 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -194,15 +194,45 @@ // registry the stub self-registers into, populated on the main thread by the //