diff --git a/.github/workflows/_build-ios-port.yml b/.github/workflows/_build-ios-port.yml index ac40f4521d..acddf3e8cb 100644 --- a/.github/workflows/_build-ios-port.yml +++ b/.github/workflows/_build-ios-port.yml @@ -69,6 +69,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/input-validation.yml b/.github/workflows/input-validation.yml index d09ab8e7c8..c929ac50df 100644 --- a/.github/workflows/input-validation.yml +++ b/.github/workflows/input-validation.yml @@ -87,6 +87,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml index 30df87c38e..e40214adfc 100644 --- a/.github/workflows/ios-packaging.yml +++ b/.github/workflows/ios-packaging.yml @@ -91,6 +91,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml index 0c1ec5ddb5..306555ea55 100644 --- a/.github/workflows/scripts-ios-native.yml +++ b/.github/workflows/scripts-ios-native.yml @@ -113,6 +113,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index b0aaeef8cb..2d2b659f0d 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -125,6 +125,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ @@ -263,6 +264,7 @@ jobs: run: | set -euo pipefail SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + maven/codenameone-maven-plugin/src/main \ -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ diff --git a/CodenameOne/src/com/codename1/annotations/Route.java b/CodenameOne/src/com/codename1/annotations/Route.java new file mode 100644 index 0000000000..6f0c5f3312 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Route.java @@ -0,0 +1,88 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a `Form` class -- or a static method that returns a `Form` -- to a +/// URL path so the framework can show it in response to a deep link. +/// +/// `@Route` is the only annotation an application needs in order to make a +/// form reachable from a Universal Link, an Android App Link, a custom-scheme +/// URL, a push-notification payload, or any other URL the platform delivers +/// to the app. Path variables flow into constructor or method parameters +/// through `RouteParam`. +/// +/// ```java +/// @Route("/users/:id") +/// public class ProfileForm extends Form { +/// public ProfileForm(@RouteParam("id") String id) { ... } +/// } +/// +/// public class Routes { +/// @Route("/home") +/// public static Form home() { +/// return new HomeForm(); +/// } +/// +/// @Route("/users/:id") +/// public static Form profile(@RouteParam("id") String id) { +/// return new ProfileForm(id); +/// } +/// } +/// ``` +/// +/// **At build time** the Codename One Maven plugin scans the project's +/// compiled bytecode, validates every `@Route` (extends `Form`, accessible +/// constructor or static factory, no duplicate patterns, every parameter +/// bound), then generates an internal dispatch class that the framework wires +/// to the platform's deep-link plumbing under the hood. There is no +/// reflection at runtime and no router API for the application to call -- +/// `new MyForm().show()` is still the way to navigate inside the app; URL +/// routing only handles links coming from outside. +/// +/// #### Path syntax +/// +/// - **Literal segments** -- `/about` +/// - **Named parameters** -- `/users/:id`, bound via `@RouteParam("id")` +/// - **Single-segment wildcard** -- `/files/*` +/// - **Catch-all wildcard** -- `/files/**` +@Retention(RetentionPolicy.CLASS) +@Target({ ElementType.TYPE, ElementType.METHOD }) +public @interface Route { + + /// The path pattern. Always starts with `/`. Required. + String value(); + + /// Container annotation for binding several path patterns to the same + /// target. Use it when a single Form should be reachable from multiple + /// URLs without repeating the body. + @Retention(RetentionPolicy.CLASS) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @interface Routes { + Route[] value(); + } +} diff --git a/CodenameOne/src/com/codename1/annotations/RouteParam.java b/CodenameOne/src/com/codename1/annotations/RouteParam.java new file mode 100644 index 0000000000..554a7f0256 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/RouteParam.java @@ -0,0 +1,61 @@ +/* + * 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.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds a constructor or static-factory parameter to a path variable or query +/// parameter from an incoming deep link. +/// +/// Used together with `Route`. The build-time route processor inspects each +/// annotated parameter and generates dispatch code that pulls the value out of +/// the matched URL before invoking the constructor or factory. +/// +/// ```java +/// @Route("/users/:id") +/// public class ProfileForm extends Form { +/// public ProfileForm(@RouteParam("id") String id) { ... } +/// } +/// +/// @Route("/search") +/// public static Form search(@RouteParam("q") String query, +/// @RouteParam(value = "page", required = false) String page) { ... } +/// ``` +/// +/// The `value` is matched first against named path variables (`:name`) and then +/// against query-string keys. The annotation is required on every parameter the +/// framework should bind; unannotated parameters are an error at build time. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface RouteParam { + + /// The name of the path variable or query parameter to bind. Required. + String value(); + + /// When true (the default) the build fails if the deep link cannot supply a + /// value. When false a missing value is passed in as null. + boolean required() default true; +} diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java new file mode 100644 index 0000000000..0a582da09e --- /dev/null +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -0,0 +1,193 @@ +/* + * 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.router; + +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// In-app navigation API on top of the declarative `@Route` table. +/// +/// `Navigation` is the imperative counterpart to the `Route` annotation: +/// declare your forms with `@Route("/users/:id")` once, then trigger +/// navigation from anywhere with `Navigation.navigate("/users/42")`. The same +/// route table that handles deep links is reused, so there is exactly one +/// place that knows how `/users/:id` maps to a form. +/// +/// The class also exposes the navigation stack so applications can render +/// breadcrumb UIs without maintaining a parallel history: +/// +/// ```java +/// Container breadcrumbs = new Container(BoxLayout.x()); +/// for (final NavigationEntry e : Navigation.getStack()) { +/// Button crumb = new Button(e.getTitle()); +/// crumb.addActionListener(evt -> Navigation.popTo(e)); +/// breadcrumbs.add(crumb); +/// } +/// ``` +/// +/// The surface is intentionally tiny -- five static methods and one value +/// type. Applications that prefer raw `Form#show` / `Form#showBack` keep +/// working unchanged; the `Navigation` stack only records URL-driven +/// navigations. +/// +/// All methods must be called on the EDT. +public final class Navigation { + + private static RouteDispatcher dispatcher; + private static final List stack = new ArrayList(); + + private Navigation() { + } + + // ------------------------------------------------------------------------ + // Internal: dispatcher installation + // ------------------------------------------------------------------------ + + /// Installs the build-time-generated route dispatcher. Invoked once by + /// `com.codename1.router.generated.Routes#bootstrap` during framework + /// initialization. Application code should not call this. + public static void setDispatcher(RouteDispatcher d) { + dispatcher = d; + } + + // ------------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------------ + + /// Navigate to a path. Looks the URL up in the route table generated from + /// `@Route` annotations, builds the matching `Form`, pushes it onto the + /// navigation stack, and shows it. + /// + /// Accepts either a bare path (`/users/42`), a full URL with scheme + + /// host (`https://example.com/users/42`), or a custom-scheme URL. Scheme + /// and host are ignored -- only the path + query are matched. + /// + /// Returns `true` when a route matched and the form was shown, `false` + /// when no route matched. + public static boolean navigate(String path) { + RouteDispatcher d = dispatcher; + if (d == null || path == null) { + return false; + } + Form f; + try { + f = d.dispatch(path); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + return false; + } + if (f == null) { + return false; + } + stack.add(new NavigationEntry(path, f)); + f.show(); + return true; + } + + /// Pop the top entry off the navigation stack and return to the previous + /// one. Uses `Form#showBack` so the transition runs in reverse. Returns + /// `true` when a frame was popped, `false` when the stack had at most one + /// entry (already at the root, nothing to go back to). + public static boolean back() { + if (stack.size() <= 1) { + return false; + } + stack.remove(stack.size() - 1); + NavigationEntry now = stack.get(stack.size() - 1); + now.getForm().showBack(); + return true; + } + + /// The current entry (top of stack), or null when the stack is empty. + public static NavigationEntry getCurrent() { + return stack.isEmpty() ? null : stack.get(stack.size() - 1); + } + + /// Unmodifiable snapshot of the navigation stack, oldest entry first + /// (breadcrumb order). The list is a copy: mutating navigations after + /// the call do not affect it. + public static List getStack() { + return Collections.unmodifiableList(new ArrayList(stack)); + } + + /// Pop entries until `entry` is on top, then show its form via + /// `Form#showBack`. Returns `true` when the entry was on the stack and + /// we navigated back to it, `false` when the entry is not on the stack. + /// Calling with the current entry is a no-op that returns `true`. + public static boolean popTo(NavigationEntry entry) { + if (entry == null) { + return false; + } + // NavigationEntry doesn't override equals, so entry.equals(other) is + // reference equality -- which is what we want here. Two navigations to + // the same path are independent stack frames. + int idx = -1; + for (int i = 0; i < stack.size(); i++) { + if (entry.equals(stack.get(i))) { + idx = i; + break; + } + } + if (idx < 0) { + return false; + } + if (idx == stack.size() - 1) { + return true; + } + while (stack.size() > idx + 1) { + stack.remove(stack.size() - 1); + } + entry.getForm().showBack(); + return true; + } + + // ------------------------------------------------------------------------ + // Internal: framework-side entry point invoked by Display when the + // platform delivers a deep link through `AppArg`. + // ------------------------------------------------------------------------ + + /// Dispatch a URL delivered by the platform. Invoked by + /// `com.codename1.ui.Display#setProperty(String, String)` for URL-shaped + /// `AppArg` values; applications should call `#navigate(String)` instead. + public static boolean dispatchExternalUrl(String url) { + if (url == null || url.length() == 0) { + return false; + } + if (Display.getInstance().isEdt()) { + return navigate(url); + } + final String captured = url; + final boolean[] holder = new boolean[1]; + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + holder[0] = navigate(captured); + } + }); + return holder[0]; + } +} diff --git a/CodenameOne/src/com/codename1/router/NavigationEntry.java b/CodenameOne/src/com/codename1/router/NavigationEntry.java new file mode 100644 index 0000000000..f7fd7d2d1e --- /dev/null +++ b/CodenameOne/src/com/codename1/router/NavigationEntry.java @@ -0,0 +1,65 @@ +/* + * 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.router; + +import com.codename1.ui.Form; + +/// A single frame on the `Navigation` stack: the URL that produced the form +/// and the `Form` instance the route built. Returned from +/// `Navigation#getStack`, `Navigation#getCurrent`, and accepted by +/// `Navigation#popTo` so a breadcrumb UI can pop back to any prior entry. +/// +/// Entries are immutable value objects; equality is by identity. +public final class NavigationEntry { + + private final String path; + private final Form form; + + NavigationEntry(String path, Form form) { + this.path = path; + this.form = form; + } + + /// The path (URL minus scheme + host) that produced this entry, e.g. + /// `/users/42`. + public String getPath() { + return path; + } + + /// The `Form` instance the route builder produced. + public Form getForm() { + return form; + } + + /// Convenience: the form's title, useful as a breadcrumb label. Returns + /// the empty string when the form has no title set. + public String getTitle() { + String t = form == null ? null : form.getTitle(); + return t == null ? "" : t; + } + + @Override + public String toString() { + return "NavigationEntry{" + path + "}"; + } +} diff --git a/CodenameOne/src/com/codename1/router/PopGuard.java b/CodenameOne/src/com/codename1/router/PopGuard.java new file mode 100644 index 0000000000..fa8802d971 --- /dev/null +++ b/CodenameOne/src/com/codename1/router/PopGuard.java @@ -0,0 +1,55 @@ +/* + * 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.router; + +import com.codename1.ui.Form; + +/// Intercept back/pop attempts on a `Form`. Install with `Form#setPopGuard(PopGuard)`. +/// +/// Typical use is to confirm before leaving a half-filled form, or to override +/// hardware back to show a custom dialog. +/// +/// #### Example +/// +/// ```java +/// editForm.setPopGuard(new PopGuard() { +/// public boolean canPop(Form form, PopReason reason) { +/// if (!isDirty()) return true; +/// Dialog.show("Discard changes?", "You have unsaved edits.", "Stay", "Discard"); +/// return false; // block the pop; we'll dismiss explicitly if user picks Discard. +/// } +/// }); +/// ``` +public interface PopGuard { + /// Decides whether a back/pop attempt should proceed. + /// + /// #### Parameters + /// - `form`: the form being popped. + /// - `reason`: what triggered the pop (back button, programmatic, etc.). + /// + /// #### Returns + /// `true` to let the navigation proceed, `false` to block it. When blocking, + /// the guard is responsible for any UI follow-up such as showing a confirm + /// dialog and re-issuing the pop programmatically once confirmed. + boolean canPop(Form form, PopReason reason); +} diff --git a/CodenameOne/src/com/codename1/router/PopReason.java b/CodenameOne/src/com/codename1/router/PopReason.java new file mode 100644 index 0000000000..df5acf653e --- /dev/null +++ b/CodenameOne/src/com/codename1/router/PopReason.java @@ -0,0 +1,52 @@ +/* + * 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.router; + +/// Why a back/pop attempt is happening. Passed to `PopGuard#canPop` so guards +/// can make different decisions for different triggers (allow programmatic +/// dismissal but warn on hardware back, for example). +public final class PopReason { + /// The Android hardware back button, the iOS edge-swipe gesture, or the + /// browser back button on the JavaScript port. + public static final PopReason HARDWARE_BACK = new PopReason("HARDWARE_BACK"); + + /// The Form's back command was invoked (toolbar back button, etc.). + public static final PopReason BACK_COMMAND = new PopReason("BACK_COMMAND"); + + /// Application code invoked a back/pop programmatically. + public static final PopReason PROGRAMMATIC = new PopReason("PROGRAMMATIC"); + + private final String name; + + private PopReason(String name) { + this.name = name; + } + + public String name() { + return name; + } + + @Override public String toString() { + return name; + } +} diff --git a/CodenameOne/src/com/codename1/router/RouteDispatcher.java b/CodenameOne/src/com/codename1/router/RouteDispatcher.java new file mode 100644 index 0000000000..f0fdaba2b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/router/RouteDispatcher.java @@ -0,0 +1,42 @@ +/* + * 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.router; + +import com.codename1.ui.Form; + +/// Internal contract between the build-time-generated route table and the +/// framework. Application code should not implement or call this directly -- +/// declare deep-linkable forms with `com.codename1.annotations.Route` and use +/// `Navigation#navigate(String)` for in-app routing. +/// +/// A single implementation, generated by the Codename One Maven plugin from +/// `@Route` annotations in the project, is installed on `Display` during +/// startup. Implementations resolve a URL to a `Form` factory and return +/// the resulting form -- they do not show the form; the caller +/// (`Navigation`) is responsible for stack bookkeeping and the +/// `Form#show` / `Form#showBack` call. +public interface RouteDispatcher { + /// Resolve a URL to a Form. Returns the freshly built form when a route + /// matched, or null otherwise. + Form dispatch(String url); +} diff --git a/CodenameOne/src/com/codename1/router/package-info.java b/CodenameOne/src/com/codename1/router/package-info.java new file mode 100644 index 0000000000..1b910831f3 --- /dev/null +++ b/CodenameOne/src/com/codename1/router/package-info.java @@ -0,0 +1,8 @@ +/// Pop-navigation and deep-link routing support. +/// +/// The application-facing surface is intentionally small: declare deep-linkable +/// forms with `com.codename1.annotations.Route`, intercept back navigation +/// with `com.codename1.ui.Form#setPopGuard(PopGuard)`, and let the framework +/// wire the URL plumbing through generated code under +/// `com.codename1.router.generated`. +package com.codename1.router; diff --git a/CodenameOne/src/com/codename1/ui/Button.java b/CodenameOne/src/com/codename1/ui/Button.java index cd54e1345b..fffa08b013 100644 --- a/CodenameOne/src/com/codename1/ui/Button.java +++ b/CodenameOne/src/com/codename1/ui/Button.java @@ -699,6 +699,16 @@ public Image getIconFromState() { protected void fireActionEvent(int x, int y) { super.fireActionEvent(); if (cmd != null) { + // PopGuard hook: if this button's command is the form's back command + // and the form has a pop guard installed, consult the guard before + // we dispatch to listeners. Vetoing here suppresses the user's back + // action listener cleanly, which is the natural pop-scope behavior. + Form f0 = getComponentForm(); + if (f0 != null && cmd == f0.getBackCommand()) { //NOPMD CompareObjectsWithEquals + if (!f0.checkPopGuard(com.codename1.router.PopReason.BACK_COMMAND)) { + return; + } + } ActionEvent ev = new ActionEvent(cmd, this, x, y); dispatcher.fireActionEvent(ev); if (!ev.isConsumed()) { diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 50b58b6fc5..382afd6da1 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -3655,6 +3655,30 @@ public void run() { } } + /// Heuristic test for URL-shaped strings. Accepts anything containing + /// `://` or a `scheme:` prefix; falls through for `AppArg` payloads that + /// happen to be non-URL data. + private static boolean looksLikeUrl(String v) { + if (v == null) { + return false; + } + if (v.indexOf("://") >= 0) { + return true; + } + int colon = v.indexOf(':'); + if (colon <= 0) { + return false; + } + for (int i = 0; i < colon; i++) { + char c = v.charAt(i); + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.')) { + return false; + } + } + return true; + } + /// Returns the property from the underlying platform deployment or the default /// value if no deployment values are supported. This is equivalent to the /// getAppProperty from the jad file. @@ -3712,6 +3736,14 @@ public String getProperty(String key, String defaultValue) { public void setProperty(String key, String value) { if ("AppArg".equals(key)) { impl.setAppArg(value); + // Every CN1 port (iOS cn1OpenURL / cn1ContinueUserActivity, Android + // onNewIntent, JS URL navigation) already pipes deep links through + // setProperty("AppArg", url). Treat URL-shaped values as deep links + // and route them through the build-time-generated dispatcher; other + // AppArg payloads (free-form launch data) are untouched. + if (value != null && value.length() > 0 && looksLikeUrl(value)) { + com.codename1.router.Navigation.dispatchExternalUrl(value); + } return; } if ("blockOverdraw".equals(key)) { diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index b2137c6785..b15167bfdd 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -135,6 +135,9 @@ public class Form extends Container { private EventDispatcher commandListener; /// Relevant for modal forms where the previous form should be rendered underneath private Form previousForm; + /// Optional guard consulted before back/pop navigation leaves this form. + /// Installed with `#setPopGuard(com.codename1.router.PopGuard)`. + private com.codename1.router.PopGuard popGuard; /// Default color for the screen tint when a dialog or a menu is shown private int tintColor; /// Listeners for key release events @@ -1537,6 +1540,56 @@ public void setBackCommand(Command backCommand) { menuBar.setBackCommand(backCommand); } + /// Installs an optional guard that is consulted before back/pop navigation + /// leaves this form. + /// + /// The guard fires for: + /// - The back command (toolbar / menu back button). + /// - Hardware back (Android back button, iOS edge-swipe back). + /// - Programmatic `com.codename1.router.Router#pop` and `replace` calls. + /// + /// If the guard returns `false` the navigation is suppressed; the guard itself + /// is responsible for any follow-up UI (e.g. showing a confirm dialog and then + /// calling `Router.pop()` once the user accepts). + /// + /// Pass `null` to remove a previously installed guard. + /// + /// #### Since 8.0 + /// + /// #### See also + /// + /// - `com.codename1.router.PopGuard` + public void setPopGuard(com.codename1.router.PopGuard guard) { + this.popGuard = guard; + } + + /// Returns the currently installed pop guard, or null. + /// + /// #### Since 8.0 + public com.codename1.router.PopGuard getPopGuard() { + return popGuard; + } + + /// Consults the installed pop guard for the given reason. Returns `true` when + /// no guard is installed or the guard permits the pop. Called by `Router`, by + /// the back-command dispatcher, by platform back-key glue, and may be called + /// by developer code that implements its own back navigation and wants to + /// honor any pop guard installed on the form. + /// + /// #### Since 8.0 + public boolean checkPopGuard(com.codename1.router.PopReason reason) { + com.codename1.router.PopGuard g = this.popGuard; + if (g == null) { + return true; + } + try { + return g.canPop(this, reason); + } catch (Throwable t) { + Log.e(t); + return true; + } + } + /// This method returns the Content pane instance /// /// #### Returns @@ -2414,6 +2467,19 @@ void actionCommandImpl(Command cmd, ActionEvent ev) { return; } + // PopGuard hook: if the dispatched command is this form's back command and + // a pop guard is installed, consult it before the back actually fires. A + // guard that vetoes the pop also consumes the event so the back-command's + // own action listener never runs. + if (popGuard != null && cmd == menuBar.getBackCommand()) { //NOPMD CompareObjectsWithEquals + if (!checkPopGuard(com.codename1.router.PopReason.BACK_COMMAND)) { + if (ev != null) { + ev.consume(); + } + return; + } + } + if (comboLock) { if (cmd == menuBar.getCancelMenuItem()) { //NOPMD CompareObjectsWithEquals actionCommand(cmd); diff --git a/CodenameOne/src/com/codename1/ui/MenuBar.java b/CodenameOne/src/com/codename1/ui/MenuBar.java index def2af47f6..def1e5e99f 100644 --- a/CodenameOne/src/com/codename1/ui/MenuBar.java +++ b/CodenameOne/src/com/codename1/ui/MenuBar.java @@ -1388,6 +1388,17 @@ public void keyReleased(int keyCode) { } } if (c != null) { + // PopGuard hook: hardware back-key path. We check before invoking the + // back command so a vetoing guard suppresses the entire back chain + // (including any user-supplied action listener registered with the + // back command). Only consults the guard for the back-command path -- + // clear/backspace keys (handled through getClearCommand above) are + // not pop events. + if (keyCode == backSK && c == parent.getBackCommand()) { //NOPMD CompareObjectsWithEquals + if (!parent.checkPopGuard(com.codename1.router.PopReason.HARDWARE_BACK)) { + return; + } + } ActionEvent ev = new ActionEvent(c, keyCode); c.actionPerformed(ev); if (!ev.isConsumed()) { diff --git a/CodenameOne/src/com/codename1/ui/Sheet.java b/CodenameOne/src/com/codename1/ui/Sheet.java index 9896c8f5f1..11d762bfbe 100644 --- a/CodenameOne/src/com/codename1/ui/Sheet.java +++ b/CodenameOne/src/com/codename1/ui/Sheet.java @@ -37,6 +37,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.EventDispatcher; +import com.codename1.util.AsyncResource; import static com.codename1.ui.ComponentSelector.$; @@ -127,6 +128,10 @@ public class Sheet extends Container { private Component titleComponent = title; private final EventDispatcher closeListeners = new EventDispatcher(); private final EventDispatcher backListeners = new EventDispatcher(); + /// Pending result resource published by `#showForResult` and completed by + /// `#finish`. When the sheet is dismissed without an explicit `finish()` the + /// resource is completed with `null` (cancellation analogue). + private AsyncResource pendingResult; private final Button backButton = new Button(FontImage.MATERIAL_CLOSE); private final Container commandsContainer = new Container(BoxLayout.x()); private final Container titleComponentContainer = FlowLayout.encloseCenterMiddle(title); @@ -728,6 +733,68 @@ public void show() { show(DEFAULT_TRANSITION_DURATION); } + /// Shows the sheet and returns an `AsyncResource` that will be completed when + /// the sheet finishes -- either with `#finish(Object)` carrying a chosen value, + /// or with `null` when the sheet is dismissed via back/swipe. + /// + /// Lets sheets be used as inline confirmation dialogs / pickers without + /// wiring up `addCloseListener` + state-shared variables: + /// + /// ```java + /// PickerSheet sheet = new PickerSheet(); + /// sheet.showForResult().ready(new SuccessCallback() { + /// public void onSuccess(String picked) { + /// if (picked != null) handle(picked); + /// } + /// }); + /// ``` + /// + /// The result type is supplied at the call site; use `Sheet#finish(Object)` + /// internally to complete it. The cast is unchecked at runtime -- pick a type + /// you control inside the sheet. + /// + /// #### Since 8.0 + public AsyncResource showForResult() { + return showForResult(DEFAULT_TRANSITION_DURATION); + } + + /// `#showForResult` with a custom slide duration. + /// + /// #### Since 8.0 + @SuppressWarnings("unchecked") + public AsyncResource showForResult(int duration) { + // Always create a fresh resource per show -- re-showing a Sheet via + // showForResult is a new transaction. + pendingResult = new AsyncResource(); + show(duration); + return (AsyncResource) pendingResult; + } + + /// Completes the result resource returned by `#showForResult` and dismisses the + /// sheet. No-op (besides dismissal) if `showForResult` was not used to open + /// this sheet. + /// + /// #### Parameters + /// - `result`: the value to deliver to the resource subscriber. May be null, + /// in which case subscribers see the same outcome as a user-initiated + /// dismissal. + /// + /// #### Since 8.0 + public void finish(Object result) { + AsyncResource r = pendingResult; + pendingResult = null; + if (r != null && !r.isDone()) { + try { + r.complete(result); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + // Dismiss the sheet: walk up parents to the root and hide. Reuse back() + // semantics so transitions match. + back(); + } + /// Shows the sheet over the current form using a slide-up transition with given duration in milliseconds. /// /// If another sheet is currently being shown, then this will replace that sheet, and use an appropriate slide @@ -1359,6 +1426,19 @@ private void fireCloseEvent(boolean parentsToo) { if (parentsToo && parentSheet != null) { parentSheet.fireCloseEvent(true); } + // Auto-resolve a pending showForResult() with null when the sheet closes + // without finish() having been called (back, swipe-dismiss, or being + // replaced by another sheet). This mirrors how Android Activity onResult + // semantics treat a cancelled return: subscribers see a null payload. + AsyncResource r = pendingResult; + if (r != null && !r.isDone()) { + pendingResult = null; + try { + r.complete(null); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } } /// Adds listener to be notified when user goes back to the parent. This is not diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 0aa6ddadd7..171e3b061c 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7067,10 +7067,30 @@ public void componentHidden(ComponentEvent e) { if (m instanceof Runnable) { Display.getInstance().callSerially((Runnable) m); } - + inInit = false; } - + + @Override + public void postInit() { + super.postInit(); + // Install the build-time-generated @Route dispatcher, if the project + // emitted one. JavaSE is the legitimate place for dynamic loading -- + // it runs unobfuscated and spins its own ClassPathLoader, so + // Class.forName resolves reliably across both the simulator + // (Executor-driven entry) and desktop production runs (entry through + // the application stub). ParparVM iOS and Android use the per-build + // application-stub direct symbol reference instead. Routes' no-arg + // constructor self-registers via Navigation#setDispatcher. + try { + Class.forName("com.codename1.router.generated.Routes").newInstance(); + } catch (ClassNotFoundException ignored) { + // No @Route in this project. + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + protected void sizeChanged(int w, int h) { try{ super.sizeChanged(w, h); diff --git a/docs/developer-guide/Deep-Links-Routing.asciidoc b/docs/developer-guide/Deep-Links-Routing.asciidoc new file mode 100644 index 0000000000..62dc61784d --- /dev/null +++ b/docs/developer-guide/Deep-Links-Routing.asciidoc @@ -0,0 +1,216 @@ +== Deep-Link Routing + +[[deep-link-routing-section,Deep-Link Routing Section]] +Codename One can dispatch a deep link -- an iOS Universal Link, an Android +App Link, a custom-scheme URL, a push-notification payload, or anything +else the platform delivers as a URL -- to a specific `Form` by class or by +static factory method. The same route table powers in-app navigation +through `Navigation.navigate("/path")`, so the application chooses +declarative or imperative per call site without maintaining two parallel +maps. + +The application surface is two annotations plus a tiny `Navigation` +class -- five static methods and one value type. + +=== Declare a route + +Annotate the target `Form` class: + +[source,java] +---- +package com.example; + +import com.codename1.annotations.Route; +import com.codename1.annotations.RouteParam; +import com.codename1.ui.Form; + +@Route("/users/:id") +public class ProfileForm extends Form { + public ProfileForm(@RouteParam("id") String id) { + setTitle("Profile " + id); + // ... + } +} +---- + +Or annotate a static factory method: + +[source,java] +---- +public class Routes { + @Route("/home") + public static Form home() { + return new HomeForm(); + } + + @Route("/users/:id") + public static Form profile(@RouteParam("id") String id) { + return new ProfileForm(id); + } +} +---- + +Both forms are supported and a project can mix them. Path variables in +the pattern (`:id`, `*`, `**`) are matched by name against parameters +annotated with `@RouteParam`. Missing `@RouteParam` on a path-variable +parameter fails the build. + +=== Path syntax + +|=== +|Pattern |Matches |Bound + +|`/about` |`/about` |-- +|`/users/:id` |`/users/42` |`id = "42"` +|`/files/*` |`/files/photo.png` (one segment) |`* = "photo.png"` +|`/files/**` |`/files/a/b/c` (catch-all) |`* = "a/b/c"` +|=== + +Query string parameters are bound the same way as path variables. The +build prefers a path-variable match for a given name and falls back to +the query string when the pattern doesn't include that name. + +=== Navigate from app code + +The `Navigation` class is the imperative counterpart to `@Route`. +Calling `Navigation.navigate("/users/42")` from anywhere in the app +resolves the URL through the same generated route table that handles +incoming deep links, builds the matching `Form`, pushes it onto the +navigation stack, and shows it. + +[source,java] +---- +Navigation.navigate("/users/42"); + +// Go back one step: +Navigation.back(); + +// Inspect the stack for a breadcrumb UI: +Container breadcrumbs = new Container(BoxLayout.x()); +for (final NavigationEntry e : Navigation.getStack()) { + Button crumb = new Button(e.getTitle()); + crumb.addActionListener(evt -> Navigation.popTo(e)); + breadcrumbs.add(crumb); +} +---- + +The five-method surface (`navigate`, `back`, `getCurrent`, `getStack`, +`popTo`) is everything the application sees -- the rest of the URL-to- +form machinery is generated. Applications that prefer raw +`new MyForm().show()` keep working unchanged; only URL-driven calls +update the `Navigation` stack. + +=== How it works + +Codename One scans the project's compiled bytecode at build time and +generates an internal dispatcher class from every `@Route` declaration. +The dispatcher is bound into the per-platform application stub before +`Display.init(...)`, so deep links delivered during launch see the route +table immediately. The validation gate catches every problem in a single +build run: + +* `@Route` declared on a class that doesn't extend `Form` +* Pattern with no leading `/` or empty value +* Path variable with no matching `@RouteParam` on the constructor / + factory +* Duplicate pattern declared on two different targets +* `@Route` on an abstract class or non-`public static` method + +=== iOS Universal Links + +Host an `apple-app-site-association` JSON file at +`https://your.domain/.well-known/apple-app-site-association` over HTTPS +without redirects. The plugin's `AasaBuilder` produces the payload: + +[source,java] +---- +String json = new com.codename1.maven.routing.AasaBuilder() + .appId("ABCD1234.com.example.app") + .addRouterPattern("/users/:id") + .addRouterPattern("/share/**") + .addPath("NOT /admin/*") + .build(); +// Write `json` to https://example.com/.well-known/apple-app-site-association +---- + +Tell iOS which domains your app claims by setting the +`ios.associatedDomains` build hint -- a comma-separated list of +`applinks:` entries. The iOS builder writes the Associated Domains +entitlement so the resulting `.ipa` is signed for those domains +automatically. + +[source,properties] +---- +codename1.arg.ios.associatedDomains=applinks:example.com,applinks:www.example.com +---- + +=== Android App Links + +Host an `assetlinks.json` file at +`https://your.domain/.well-known/assetlinks.json`. The plugin's +`AssetLinksBuilder` produces the payload: + +[source,java] +---- +String json = new com.codename1.maven.routing.AssetLinksBuilder() + .addApp("com.example.app", + "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5") + .addFingerprint("AB:CD:..." /* Play App Signing upload cert */) + .build(); +---- + +Tell Android which URLs to intercept by setting the +`android.xintent_filter` build hint with a verified intent filter for +your domain. The Android builder injects the filter into the manifest. + +The SHA-256 fingerprint comes from `keytool -list -v -keystore ...`, or +from the Play Console under **Setup > App integrity** when using Play +App Signing. + +=== Testing + +==== iOS Simulator + +[source,sh] +---- +xcrun simctl openurl booted "https://example.com/users/42" +---- + +==== Android Emulator + +[source,sh] +---- +adb shell am start -a android.intent.action.VIEW \ + -d "https://example.com/users/42" com.example.app +---- + +==== Desktop Simulator + +Pass `--cn1-arg=https://example.com/users/42` on the run command, or use +the simulator's URL-injection helper. + +=== Back-press guard + +Independent of deep linking, individual forms can intercept back / pop +attempts so they can confirm before discarding unsaved work: + +[source,java] +---- +import com.codename1.router.PopGuard; +import com.codename1.router.PopReason; + +editForm.setPopGuard(new PopGuard() { + public boolean canPop(Form form, PopReason reason) { + if (!isDirty()) { + return true; + } + Dialog.show("Discard changes?", "You have unsaved edits.", + "Stay", "Discard"); + return false; + } +}); +---- + +The guard fires for the toolbar back button, the Android hardware back +key, the iOS edge-swipe gesture, and any programmatic back navigation. +`PopReason` distinguishes the trigger so a guard can be selective. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 038f166c15..1bb480d2a9 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -88,6 +88,8 @@ include::Biometric-Authentication.asciidoc[] include::Authentication-And-Identity.asciidoc[] +include::Deep-Links-Routing.asciidoc[] + include::Near-Field-Communication.asciidoc[] include::Network-Connectivity.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 7c0e286850..c748481b10 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -510,6 +510,11 @@ Keycloak Cognito Authentik +# Codename One router & JS port - technical identifiers used in the Routing +# and Deep Links chapters. +parparvm +assetlinks + # WebAuthn / passkey terminology used in the Authentication & Identity chapter. # IdP is the standard W3C abbreviation for "identity provider"; the # server-library names below appear verbatim in the recommended-libraries diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml index e8405cf321..87ad6cd23e 100644 --- a/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/common/pom.xml @@ -371,6 +371,12 @@ compliance-check css + + process-annotations diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 0c0719bcbd..e21576715e 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -44,6 +44,29 @@ junit test + + + org.junit.vintage + junit-vintage-engine + ${junit.jupiter.version} + test + + + + ${project.groupId} + codenameone-core + ${project.version} + test + ${project.groupId} codenameone-designer @@ -240,7 +263,11 @@ maven-surefire-plugin - 2.22.1 + + 3.2.5 maven-jar-plugin @@ -280,8 +307,8 @@ org.apache.maven.plugins maven-surefire-plugin - 2.22.1 - + + 3.2.5 org.apache.maven.plugins diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 4e66757796..2bcde9ccb7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2837,9 +2837,15 @@ public void usesClassMethod(String cls, String method) { + " }\n"; - String reinitCode0 = "Display.init(this);\n"; + // Install the build-time-generated @Route dispatcher before the + // first Display init. The reinit branch doesn't repeat the call + // because Navigation#setDispatcher writes a static field that + // survives a Display reinit. See Executor#routeDispatcher + // InstallSource for the conditional emission and obfuscation + // reasoning. + String installRoutes = routeDispatcherInstallSource(sourceZip, " "); - reinitCode0 = "AndroidImplementation.startContext(this);\n"; + String reinitCode0 = installRoutes + " AndroidImplementation.startContext(this);\n"; String reinitCode = "Display.init(this);\n"; @@ -5272,4 +5278,5 @@ private void stripKotlin(File dummyClassesDir) { } } } + } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index 4379fe368c..a56d4e1702 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -1984,4 +1984,35 @@ protected Properties getLocalBuilderProperties() { return localBuilderProperties; } + + /// Returns true when the project's `jar-with-dependencies` (the + /// `sourceZip` passed to `build(...)`) contains the build-time + /// generated `com.codename1.router.generated.Routes` class. + protected static boolean projectHasRouteDispatcher(File sourceZip) { + if (sourceZip == null || !sourceZip.isFile()) { + return false; + } + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceZip)) { + return zf.getEntry("com/codename1/router/generated/Routes.class") != null; + } catch (IOException e) { + return false; + } + } + + /// Stub-source fragment to splice into a generated application stub + /// right before `Display.init(...)` to install the build-time + /// generated `@Route` dispatcher. Empty when the project ships no + /// Routes class, so legacy apps without the annotation Mojo still + /// produce a clean stub. The dispatcher's no-arg constructor self- + /// registers via `Navigation#setDispatcher` -- direct symbol + /// reference, not `Class.forName`, so ParparVM / R8 obfuscation + /// rewrites the call site and the generated class together and the + /// binding survives in shipped builds. `indent` is the leading + /// whitespace that matches the surrounding stub source. + protected static String routeDispatcherInstallSource(File sourceZip, String indent) { + if (!projectHasRouteDispatcher(sourceZip)) { + return ""; + } + return indent + "new com.codename1.router.generated.Routes();\n"; + } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 51f93d1907..bb4aeba0e6 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1207,8 +1207,8 @@ public void usesClassMethod(String cls, String method) { + " " + request.getMainClass() + "Stub stub = new " + request.getMainClass() + "Stub();\n" + " com.codename1.impl.ios.IOSImplementation.setMainClass(stub.i);\n" + " com.codename1.impl.ios.IOSImplementation.setIosMode(\"" + iosMode + "\");\n" + + routeDispatcherInstallSource(sourceZip, " ") + " Display.init(stub);\n" - + " }\n" + "}\n"; @@ -4101,7 +4101,4 @@ private static String join(String[] strs, String sep) { return out.toString(); } - - - } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java new file mode 100644 index 0000000000..fd5dca592c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateAnnotationStubsMojo.java @@ -0,0 +1,151 @@ +/* + * 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.maven; + +import com.codename1.maven.annotations.AnnotationProcessor; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +/// Codegen Mojo bound to `generate-sources`. Walks every registered +/// `AnnotationProcessor` and asks it to emit its compile-time stub sources, +/// which are written under `target/generated-sources/cn1-annotations` and +/// added to the project's compile source roots so the next `compile` phase +/// picks them up. +/// +/// Stubs exist so application code can reference symbols (e.g. +/// `com.codename1.router.generated.RoutesIndex.register()`) that the +/// `process-annotations` PROCESS_CLASSES pass will later overwrite with the +/// real implementation. The stub keeps **compile-time** references resolvable; +/// the rewritten `.class` provides the **runtime** behavior. +/// +/// If `process-annotations` is not configured the stubs remain as no-ops so the +/// app still builds — but it sees no registered routes at runtime. This is the +/// least-surprise default for users experimenting with annotations. +@Mojo(name = "generate-annotation-stubs", + defaultPhase = LifecyclePhase.GENERATE_SOURCES, + threadSafe = true) +public class GenerateAnnotationStubsMojo extends AbstractCN1Mojo { + + // The MavenProject reference is inherited from AbstractCN1Mojo. + + @Parameter(defaultValue = "${project.build.directory}/generated-sources/cn1-annotations", + required = true) + protected File stubSourceDirectory; + + @Parameter(defaultValue = "false") + protected boolean skip; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (skip) { + getLog().info("cn1: generate-annotation-stubs skipped by configuration"); + return; + } + + List processors = loadProcessors(); + if (processors.isEmpty()) { + getLog().debug("cn1: no AnnotationProcessor services registered — nothing to do"); + return; + } + + if (!stubSourceDirectory.exists() && !stubSourceDirectory.mkdirs()) { + throw new MojoExecutionException("Could not create " + stubSourceDirectory); + } + + File outputDir = new File(project.getBuild().getOutputDirectory()); + ProcessorContext ctx = new ProcessorContext(outputDir, stubSourceDirectory, + /*classIndex*/ java.util.Collections.emptyMap(), + getLog()); + + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + try { + p.emitStubs(ctx); + } catch (ProcessingException e) { + throw new MojoFailureException( + "Annotation processor " + p.getClass().getName() + " failed to emit stubs: " + + e.getMessage(), e); + } + } + + Map stubs = ctx.getEmittedStubSources(); + for (Map.Entry e : stubs.entrySet()) { + File f = new File(stubSourceDirectory, e.getKey() + ".java"); + File parent = f.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new MojoExecutionException("Could not create " + parent); + } + try { + writeUtf8(f, e.getValue()); + } catch (IOException ioe) { + throw new MojoExecutionException("Could not write stub " + f, ioe); + } + } + + project.addCompileSourceRoot(stubSourceDirectory.getAbsolutePath()); + if (!stubs.isEmpty()) { + getLog().info("cn1: emitted " + stubs.size() + " annotation stub(s) under " + + stubSourceDirectory); + } + } + + private List loadProcessors() { + // ServiceLoader against this plugin's classloader. The processors live + // in this artifact, so the plugin's loader sees them by default. We + // expose this as a separate method so plugin-test fixtures can subclass + // and inject custom processors. + ServiceLoader sl = ServiceLoader.load( + AnnotationProcessor.class, AnnotationProcessor.class.getClassLoader()); + List out = new ArrayList(); + for (AnnotationProcessor p : sl) out.add(p); + return out; + } + + private static void writeUtf8(File f, String content) throws IOException { + FileOutputStream fos = new FileOutputStream(f); + Writer w = new OutputStreamWriter(fos, "UTF-8"); + try { + w.write(content); + w.flush(); + } finally { + w.close(); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java new file mode 100644 index 0000000000..ee3ed3b221 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java @@ -0,0 +1,203 @@ +/* + * 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.maven; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationProcessor; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +/// PROCESS_CLASSES Mojo. ASM-scans the project's compiled `.class` files, +/// dispatches each annotated class to the registered `AnnotationProcessor`s, +/// and writes the emitted bytecode back into `target/classes` so it lives in +/// the same tree as the rest of the compile output. +/// +/// **Fail-fast**: any processor-reported error (e.g. `@Route` on a class that +/// doesn't extend `Form`) aborts the build with a `MojoFailureException` +/// listing every offender. The Mojo never overwrites generated files when a +/// validation error is pending — invalid input cannot leak past this Mojo. +/// +/// Generated classes are emitted under `${project.build.outputDirectory}` so: +/// 1. The maven build's normal jar-packaging copies them. +/// 2. ParparVM's iOS class scan and the JavaSE simulator both see them. +/// 3. The project's `target/classes` takes precedence over any cn1-core +/// JAR stub of the same internal name on the classpath at runtime. +@Mojo(name = "process-annotations", + defaultPhase = LifecyclePhase.PROCESS_CLASSES, + threadSafe = true) +public class ProcessAnnotationsMojo extends AbstractCN1Mojo { + + // The MavenProject reference is inherited from AbstractCN1Mojo. + + @Parameter(defaultValue = "${project.build.outputDirectory}", required = true) + protected File outputDirectory; + + @Parameter(defaultValue = "${project.build.directory}/generated-sources/cn1-annotations", + required = true) + protected File stubSourceDirectory; + + @Parameter(defaultValue = "false") + protected boolean skip; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + if (skip) { + getLog().info("cn1: process-annotations skipped by configuration"); + return; + } + if (!outputDirectory.isDirectory()) { + getLog().debug("cn1: nothing compiled at " + outputDirectory + " — skipping process-annotations"); + return; + } + + List processors = loadProcessors(); + if (processors.isEmpty()) { + getLog().debug("cn1: no AnnotationProcessor services registered — nothing to do"); + return; + } + + Map index; + try { + index = ClassScanner.scan(outputDirectory); + } catch (ProcessingException e) { + throw new MojoExecutionException("Failed to scan compiled classes under " + + outputDirectory + ": " + e.getMessage(), e); + } + + ProcessorContext ctx = new ProcessorContext(outputDirectory, stubSourceDirectory, + index, getLog()); + + // start() + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + try { + p.start(ctx); + } catch (ProcessingException e) { + throw new MojoFailureException( + "Annotation processor " + p.getClass().getName() + " start failed: " + + e.getMessage(), e); + } + } + + // processClass() — dispatched only when the class carries an annotation + // the processor declares interest in. + for (AnnotatedClass cls : index.values()) { + if (cls.getClassAnnotations().isEmpty()) continue; + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + if (intersects(p.getAnnotationDescriptors(), cls.getClassAnnotations().keySet())) { + try { + p.processClass(cls, ctx); + } catch (ProcessingException e) { + throw new MojoFailureException( + "Annotation processor " + p.getClass().getName() + " failed on class " + + cls.getBinaryName() + ": " + e.getMessage(), e); + } + } + } + } + + // finish() + for (Iterator it = processors.iterator(); it.hasNext(); ) { + AnnotationProcessor p = it.next(); + try { + p.finish(ctx); + } catch (ProcessingException e) { + throw new MojoFailureException( + "Annotation processor " + p.getClass().getName() + " finish failed: " + + e.getMessage(), e); + } + } + + // Fail-fast: surface every recoverable error and abort if any. + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("Codename One annotation processing failed:\n"); + List errs = ctx.getErrors(); + for (int i = 0; i < errs.size(); i++) { + sb.append(" - ").append(errs.get(i)).append('\n'); + } + sb.append("Aborting before any generated class is written, so the build output reflects the source."); + throw new MojoFailureException(sb.toString()); + } + + // Flush emitted bytecode. + Map emitted = ctx.getEmittedClasses(); + for (Map.Entry e : emitted.entrySet()) { + File target = new File(outputDirectory, e.getKey() + ".class"); + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new MojoExecutionException("Could not create " + parent); + } + try { + FileOutputStream fos = new FileOutputStream(target); + try { + fos.write(e.getValue()); + } finally { + fos.close(); + } + } catch (IOException ioe) { + throw new MojoExecutionException("Could not write generated class " + target, ioe); + } + } + + if (!emitted.isEmpty()) { + getLog().info("cn1: emitted " + emitted.size() + " generated class(es) under " + + outputDirectory); + } + } + + private static boolean intersects(java.util.Set a, java.util.Set b) { + if (a == null || b == null || a.isEmpty() || b.isEmpty()) return false; + if (a.size() > b.size()) { + for (String s : b) if (a.contains(s)) return true; + } else { + for (String s : a) if (b.contains(s)) return true; + } + return false; + } + + private List loadProcessors() { + ServiceLoader sl = ServiceLoader.load( + AnnotationProcessor.class, AnnotationProcessor.class.getClassLoader()); + List out = new ArrayList(); + for (AnnotationProcessor p : sl) out.add(p); + return Collections.unmodifiableList(out); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java new file mode 100644 index 0000000000..5a7fb42e86 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AbstractAnnotationProcessor.java @@ -0,0 +1,62 @@ +/* + * 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.maven.annotations; + +/// Convenience base class so processors only override the lifecycle hooks they +/// actually need. The plugin compiles at Java 7 so we can't rely on default +/// methods in `AnnotationProcessor`. +public abstract class AbstractAnnotationProcessor implements AnnotationProcessor { + + @Override + public void emitStubs(ProcessorContext ctx) throws ProcessingException { + // No-op default. + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + // No-op default. + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + // No-op default. + } + + /// Helper: walks the class index following `superInternalName` links to test + /// whether `cls` is a subtype of the given JVM internal type. Returns false + /// once the chain leaves the project (the JDK / dependency classes aren't in + /// the index). Use this for the typical "must extend Form" checks. + protected static boolean isSubtypeWithinProject(AnnotatedClass cls, String superInternalName, + ProcessorContext ctx) { + if (cls == null || superInternalName == null) return false; + if (superInternalName.equals(cls.getInternalName())) return true; + if (superInternalName.equals(cls.getSuperInternalName())) return true; + AnnotatedClass parent = ctx.lookup(cls.getSuperInternalName()); + while (parent != null) { + if (superInternalName.equals(parent.getInternalName())) return true; + if (superInternalName.equals(parent.getSuperInternalName())) return true; + parent = ctx.lookup(parent.getSuperInternalName()); + } + return false; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java new file mode 100644 index 0000000000..7bdcc253c3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotatedClass.java @@ -0,0 +1,110 @@ +/* + * 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.maven.annotations; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.objectweb.asm.Opcodes; + +/// Immutable snapshot of a class produced by `ClassScanner`. +/// +/// `internalName` is the JVM internal form (e.g. `com/example/ProfileForm`); +/// most processors only ever care about the internal name. The supplemental +/// `binaryName()` method returns the `.`-form (`com.example.ProfileForm`) when +/// embedding into generated source or bytecode invocations. +public final class AnnotatedClass { + + private final String internalName; + private final String superInternalName; + private final List interfaceInternalNames; + private final int access; + private final Map annotations; + private final List methods; + private final List fields; + private final File classFile; + + AnnotatedClass( + String internalName, + String superInternalName, + List interfaceInternalNames, + int access, + Map annotations, + List methods, + List fields, + File classFile) { + this.internalName = internalName; + this.superInternalName = superInternalName; + this.interfaceInternalNames = interfaceInternalNames == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(interfaceInternalNames)); + this.access = access; + this.annotations = annotations == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(annotations)); + this.methods = methods == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(methods)); + this.fields = fields == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(fields)); + this.classFile = classFile; + } + + /// JVM internal name (`com/example/ProfileForm`). + public String getInternalName() { return internalName; } + + /// Dotted binary name (`com.example.ProfileForm`). + public String getBinaryName() { return internalName.replace('/', '.'); } + + public String getSuperInternalName() { return superInternalName; } + public List getInterfaceInternalNames() { return interfaceInternalNames; } + public int getAccess() { return access; } + + public boolean isAbstract() { return (access & Opcodes.ACC_ABSTRACT) != 0; } + public boolean isInterface() { return (access & Opcodes.ACC_INTERFACE) != 0; } + public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } + public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } + + /// Class-level annotations, keyed by JVM descriptor. + public Map getClassAnnotations() { return annotations; } + + /// Looks up a class-level annotation by descriptor, returning null if absent. + public AnnotationValues getClassAnnotation(String descriptor) { return annotations.get(descriptor); } + + public List getMethods() { return methods; } + public List getFields() { return fields; } + + /// The `.class` file this snapshot was loaded from. Useful for log/error + /// messages so users see the on-disk location of the offending class. + public File getClassFile() { return classFile; } + + @Override + public String toString() { + return internalName; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java new file mode 100644 index 0000000000..05338ada79 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationProcessor.java @@ -0,0 +1,80 @@ +/* + * 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.maven.annotations; + +import java.util.Set; + +/// SPI for build-time annotation processors that consume the compiled bytecode +/// of a Codename One project. +/// +/// Processors are discovered by Java's `ServiceLoader` mechanism: each +/// implementation must be registered in +/// `META-INF/services/com.codename1.maven.annotations.AnnotationProcessor`. +/// +/// Lifecycle (driven by `ProcessAnnotationsMojo` in PROCESS_CLASSES, and +/// `GenerateAnnotationStubsMojo` in GENERATE_SOURCES): +/// +/// 1. `#getAnnotationDescriptors` — collected once. +/// 2. `#emitStubs` — invoked during GENERATE_SOURCES (before any class +/// exists yet). Processors that need a compile-time stub so application +/// code can reference a yet-to-be-generated symbol emit it here. +/// 3. `#start` — invoked once per processor at the beginning of +/// PROCESS_CLASSES, after the class index has been built. +/// 4. `#processClass` — invoked for every class in the project that has at +/// least one annotation matching `getAnnotationDescriptors`. +/// 5. `#finish` — invoked after all classes have been processed. Processors +/// typically emit their generated bytecode here. +/// +/// All callbacks run on the Maven build thread. Processors should not retain +/// state across builds — `start` is the place to (re)initialize. +/// +/// **Fail-fast contract:** processors that detect malformed input should +/// accumulate errors via `ProcessorContext#error` so a single build run shows +/// every offending class. Throw `ProcessingException` only for non-recoverable +/// problems (corrupt class files, IO failures, etc.). +public interface AnnotationProcessor { + + /// The set of annotation descriptors this processor cares about, in JVM + /// internal form (e.g. `Lcom/codename1/annotations/Route;`). Must be + /// non-null and non-empty; an empty set would mean "scan everything" and + /// is not supported (it would defeat the dispatch optimization). + Set getAnnotationDescriptors(); + + /// GENERATE_SOURCES hook. Default implementation does nothing. + /// Processors that need a compile-time stub emit it here via + /// `ProcessorContext#emitStubSource`. + /// + /// (Plugin compiles at Java 7 source level — interfaces here use explicit + /// no-op classes rather than default methods.) + void emitStubs(ProcessorContext ctx) throws ProcessingException; + + /// Invoked once before any `#processClass` call. + void start(ProcessorContext ctx) throws ProcessingException; + + /// Invoked once per matching class. + void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException; + + /// Invoked after every `processClass` has returned. The processor emits + /// its generated bytecode via `ProcessorContext#emitClass` here. + void finish(ProcessorContext ctx) throws ProcessingException; +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationValues.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationValues.java new file mode 100644 index 0000000000..76ec4ff442 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/AnnotationValues.java @@ -0,0 +1,97 @@ +/* + * 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.maven.annotations; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// Holds the literal element values parsed off a single annotation occurrence. +/// +/// Values follow ASM's `AnnotationVisitor` conventions: +/// - Primitives are boxed (`Integer`, `Boolean`, ...). +/// - Strings stay as `String`. +/// - Class literals become `org.objectweb.asm.Type` instances. +/// - Enum constants become `String[] { internalName, valueName }` pairs. +/// - Arrays become `java.util.List`. +/// - Nested annotations become further `AnnotationValues` instances. +/// +/// The wrapper exposes a small set of typed getters so processors don't have to +/// know which form a given element comes back as. Missing keys return `null` — +/// callers that require a value should check for null and emit a clear error +/// message rather than NPE'ing on missing input. +public final class AnnotationValues { + + private final String descriptor; + private final Map values; + + AnnotationValues(String descriptor, Map values) { + this.descriptor = descriptor; + // Hold the live reference: the ClassScanner instantiates this object up + // front and then populates the map as ASM dispatches `visit()` callbacks. + // Copying here would freeze an empty snapshot before the values arrive. + this.values = (values == null) ? new LinkedHashMap() : values; + } + + /// The annotation's JVM internal descriptor, e.g. `Lcom/codename1/annotations/Route;`. + public String getDescriptor() { return descriptor; } + + /// Returns the raw value for the named element, or null. The returned object + /// follows the ASM conventions documented in this class. + public Object get(String name) { return values.get(name); } + + /// Returns the value as a String, or null if absent or not a string. Use + /// `#getStringOrDefault` when you want a typed fallback. + public String getString(String name) { + Object v = values.get(name); + return (v instanceof String) ? (String) v : null; + } + + /// Returns the string value, or `defaultValue` if absent. + public String getStringOrDefault(String name, String defaultValue) { + String s = getString(name); + return s == null ? defaultValue : s; + } + + /// Returns the int value, or `defaultValue` if absent. Accepts any boxed + /// `Number`, narrowing via `intValue()`. + public int getIntOrDefault(String name, int defaultValue) { + Object v = values.get(name); + if (v instanceof Number) return ((Number) v).intValue(); + return defaultValue; + } + + /// Returns the boolean value, or `defaultValue` if absent. + public boolean getBoolOrDefault(String name, boolean defaultValue) { + Object v = values.get(name); + return (v instanceof Boolean) ? ((Boolean) v).booleanValue() : defaultValue; + } + + /// Unmodifiable view of all element values in declaration order. + public Map all() { return Collections.unmodifiableMap(values); } + + @Override + public String toString() { + return "@" + descriptor + values; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java new file mode 100644 index 0000000000..a215db65f3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java @@ -0,0 +1,285 @@ +/* + * 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.maven.annotations; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.objectweb.asm.AnnotationVisitor; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.FieldVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/// Walks a directory of compiled `.class` files and produces the +/// `AnnotatedClass` index passed to processors. +/// +/// Uses ASM's `ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | +/// ClassReader.SKIP_FRAMES` flags — we only care about declarations +/// (annotations, signatures), never method bodies. This keeps scanning fast +/// even on large projects. +/// +/// **Fail-fast policy**: a corrupt or unreadable `.class` aborts the whole +/// scan via `ProcessingException`. Synthetic classes (e.g. anonymous inner +/// `$1.class` generated by javac) are still included — processors decide +/// whether to filter them by checking `AnnotatedClass#isSynthetic`. +public final class ClassScanner { + + /// ASM API level. Match BytecodeComplianceMojo's choice (Opcodes.ASM9) so + /// the two passes stay consistent. + private static final int API = Opcodes.ASM9; + + private ClassScanner() { } + + /// Scans every `.class` file under `root` recursively. Returns the index + /// keyed by JVM internal name. + public static Map scan(File root) throws ProcessingException { + Map out = new LinkedHashMap(); + if (root == null || !root.isDirectory()) return out; + scan(root, out); + return out; + } + + private static void scan(File dir, Map out) throws ProcessingException { + File[] children = dir.listFiles(); + if (children == null) return; + for (int i = 0; i < children.length; i++) { + File f = children[i]; + if (f.isDirectory()) { + scan(f, out); + } else if (f.isFile() && f.getName().endsWith(".class")) { + AnnotatedClass cls = readClass(f); + if (cls != null) out.put(cls.getInternalName(), cls); + } + } + } + + /// Reads a single `.class` file. Exposed for unit tests. + public static AnnotatedClass readClass(File file) throws ProcessingException { + FileInputStream fin = null; + BufferedInputStream bin = null; + try { + fin = new FileInputStream(file); + bin = new BufferedInputStream(fin); + return readClass(bin, file); + } catch (IOException e) { + throw new ProcessingException("Could not read " + file + ": " + e.getMessage(), e); + } finally { + // Best-effort close. We've already either returned the parsed class + // or raised ProcessingException for the read failure; a close-side + // IOException at this point can't change the outcome, so swallow it + // with a debug-only trace rather than masking the original error. + closeQuietly(bin); + if (bin == null) closeQuietly(fin); + } + } + + private static void closeQuietly(java.io.Closeable c) { + if (c == null) return; + try { + c.close(); + } catch (IOException ignored) { + // Intentional: see readClass(File) finally block. + } + } + + /// Reads a class from an arbitrary input stream. `source` is used only for + /// error messages; pass null when unavailable. + public static AnnotatedClass readClass(InputStream in, File source) throws ProcessingException { + try { + ClassReader reader = new ClassReader(in); + Collector c = new Collector(source); + reader.accept(c, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return c.build(); + } catch (IOException e) { + throw new ProcessingException("Could not read class from " + source + ": " + e.getMessage(), e); + } catch (RuntimeException e) { + throw new ProcessingException("ASM failed parsing " + source + ": " + e.getMessage(), e); + } + } + + // ------------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------------ + + private static final class Collector extends ClassVisitor { + private final File source; + private String internalName; + private String superInternalName; + private List interfaces; + private int access; + private final Map classAnnotations = new LinkedHashMap(); + private final List methods = new ArrayList(); + private final List fields = new ArrayList(); + + Collector(File source) { + super(API); + this.source = source; + } + + AnnotatedClass build() { + return new AnnotatedClass( + internalName, superInternalName, interfaces, access, + classAnnotations, methods, fields, source); + } + + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfacesArr) { + this.access = access; + this.internalName = name; + this.superInternalName = superName; + if (interfacesArr != null) { + this.interfaces = new ArrayList(interfacesArr.length); + Collections.addAll(this.interfaces, interfacesArr); + } + } + + @Override + public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + Map values = new LinkedHashMap(); + classAnnotations.put(descriptor, new AnnotationValues(descriptor, values)); + return new AnnotationCollector(API, values); + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + final int mAccess = access; + final String mName = name; + final String mDesc = descriptor; + final Map mAnnotations = + new LinkedHashMap(); + return new MethodVisitor(API) { + @Override + public AnnotationVisitor visitAnnotation(String d, boolean v) { + Map values = new LinkedHashMap(); + mAnnotations.put(d, new AnnotationValues(d, values)); + return new AnnotationCollector(API, values); + } + + @Override + public void visitEnd() { + methods.add(new MethodInfo(mName, mDesc, mAccess, mAnnotations)); + } + }; + } + + @Override + public FieldVisitor visitField(int access, String name, String descriptor, + String signature, Object value) { + final int fAccess = access; + final String fName = name; + final String fDesc = descriptor; + final Map fAnnotations = + new LinkedHashMap(); + return new FieldVisitor(API) { + @Override + public AnnotationVisitor visitAnnotation(String d, boolean v) { + Map values = new LinkedHashMap(); + fAnnotations.put(d, new AnnotationValues(d, values)); + return new AnnotationCollector(API, values); + } + + @Override + public void visitEnd() { + fields.add(new FieldInfo(fName, fDesc, fAccess, fAnnotations)); + } + }; + } + } + + /// Captures annotation element values into a `Map`. Handles + /// nested annotations and arrays recursively, matching ASM's contracts. + private static final class AnnotationCollector extends AnnotationVisitor { + private final Map values; + + AnnotationCollector(int api, Map values) { + super(api); + this.values = values; + } + + @Override + public void visit(String name, Object value) { + values.put(name, value); + } + + @Override + public void visitEnum(String name, String descriptor, String value) { + values.put(name, new String[] { descriptor, value }); + } + + @Override + public AnnotationVisitor visitAnnotation(String name, String descriptor) { + Map nested = new LinkedHashMap(); + values.put(name, new AnnotationValues(descriptor, nested)); + return new AnnotationCollector(api, nested); + } + + @Override + public AnnotationVisitor visitArray(String name) { + final List items = new ArrayList(); + values.put(name, items); + return new AnnotationVisitor(api) { + @Override + public void visit(String n, Object value) { items.add(value); } + + @Override + public void visitEnum(String n, String descriptor, String value) { + items.add(new String[] { descriptor, value }); + } + + @Override + public AnnotationVisitor visitAnnotation(String n, String descriptor) { + Map nested = new LinkedHashMap(); + items.add(new AnnotationValues(descriptor, nested)); + return new AnnotationCollector(api, nested); + } + + @Override + public AnnotationVisitor visitArray(String n) { + // Nested arrays are extremely rare in annotations; recurse. + final List nested = new ArrayList(); + items.add(nested); + return collectInto(api, nested); + } + }; + } + } + + private static AnnotationVisitor collectInto(int api, final List sink) { + return new AnnotationVisitor(api) { + @Override + public void visit(String n, Object value) { sink.add(value); } + }; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java new file mode 100644 index 0000000000..428c8f19db --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java @@ -0,0 +1,59 @@ +/* + * 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.maven.annotations; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.objectweb.asm.Opcodes; + +/// Lightweight description of a field discovered during the class-scanning +/// pass. +public final class FieldInfo { + + private final String name; + private final String descriptor; + private final int access; + private final Map annotations; + + FieldInfo(String name, String descriptor, int access, Map annotations) { + this.name = name; + this.descriptor = descriptor; + this.access = access; + this.annotations = (annotations == null) + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(annotations)); + } + + public String getName() { return name; } + public String getDescriptor() { return descriptor; } + public int getAccess() { return access; } + + public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } + public boolean isStatic() { return (access & Opcodes.ACC_STATIC) != 0; } + public boolean isFinal() { return (access & Opcodes.ACC_FINAL) != 0; } + + public Map getAnnotations() { return annotations; } + public AnnotationValues getAnnotation(String descriptor) { return annotations.get(descriptor); } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java new file mode 100644 index 0000000000..01c4f757ce --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/JavaSourceCompiler.java @@ -0,0 +1,158 @@ +/* + * 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.maven.annotations; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class JavaSourceCompiler { + + private JavaSourceCompiler() { } + + /// Compiles the given `fullyQualifiedName -> source` map into `.class` files + /// rooted at `outputClassDir`. Adds `extraClasspath` (typically the plugin's + /// own test-classes directory so the @Route + Form + Router stubs resolve). + public static void compile(Map sources, File outputClassDir, List extraClasspath) + throws IOException { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException( + "No JavaCompiler available -- JSR 199 requires a JDK, not a JRE"); + } + DiagnosticCollector diags = new DiagnosticCollector(); + StandardJavaFileManager fm = compiler.getStandardFileManager( + diags, Locale.ROOT, StandardCharsets.UTF_8); + try { + if (!outputClassDir.exists() && !outputClassDir.mkdirs()) { + throw new IOException("Could not create " + outputClassDir); + } + fm.setLocation(StandardLocation.CLASS_OUTPUT, + Collections.singletonList(outputClassDir)); + + // Build the classpath: pre-existing classpath + extras. Surefire + // sets `surefire.test.class.path` to the resolved test classpath + // when it forks the JVM; under newer surefire releases + // `java.class.path` only carries the surefire-booter jar, not the + // project's deps. Prefer the surefire-provided value when set so + // generated-source compilation can see test-scoped jars (e.g. + // codenameone-core for @Route fixtures). Also walk the current + // classloader's URL list as a last-resort fallback. + String surefireCp = System.getProperty("surefire.test.class.path"); + String existing = (surefireCp != null && surefireCp.length() > 0) + ? surefireCp + : System.getProperty("java.class.path", ""); + List cp = new ArrayList(); + if (existing.length() > 0) { + for (String s : existing.split(File.pathSeparator)) { + cp.add(new File(s)); + } + } + ClassLoader loader = JavaSourceCompiler.class.getClassLoader(); + if (loader instanceof java.net.URLClassLoader) { + for (java.net.URL u : ((java.net.URLClassLoader) loader).getURLs()) { + if ("file".equals(u.getProtocol())) { + File f = urlToFile(u); + if (f != null) { + cp.add(f); + } + } + } + } + if (extraClasspath != null) cp.addAll(extraClasspath); + fm.setLocation(StandardLocation.CLASS_PATH, cp); + + List compilationUnits = new ArrayList(); + for (Map.Entry e : sources.entrySet()) { + compilationUnits.add(new InMemorySource(e.getKey(), e.getValue())); + } + StringWriter compilerOut = new StringWriter(); + JavaCompiler.CompilationTask task = compiler.getTask( + compilerOut, fm, diags, + Arrays.asList("-Xlint:none", "-proc:none"), + /*classes*/ null, compilationUnits); + Boolean ok = task.call(); + if (ok == null || !ok.booleanValue()) { + StringBuilder sb = new StringBuilder("Compilation failed:\n"); + for (Diagnostic d : diags.getDiagnostics()) { + sb.append(" ").append(d.toString()).append('\n'); + } + sb.append("compiler output: ").append(compilerOut.toString()); + throw new IOException(sb.toString()); + } + } finally { + fm.close(); + } + } + + /// Convert a `file:` URL to a `File`. Returns null when the URL can't be + /// turned into a path (non-hierarchical URI, opaque URL, etc.) so callers + /// can simply skip the entry instead of failing the build. + private static File urlToFile(java.net.URL u) { + try { + return new File(u.toURI()); + } catch (java.net.URISyntaxException e) { + return null; + } catch (IllegalArgumentException e) { + return null; + } + } + + public static Map singleSource(String fqn, String src) { + Map m = new HashMap(); + m.put(fqn, src); + return m; + } + + private static final class InMemorySource extends javax.tools.SimpleJavaFileObject { + private final String content; + + InMemorySource(String fullyQualifiedName, String content) { + super(URI.create("string:///" + fullyQualifiedName.replace('.', '/') + + JavaFileObject.Kind.SOURCE.extension), JavaFileObject.Kind.SOURCE); + this.content = content; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return content; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java new file mode 100644 index 0000000000..395dfe22f9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/MethodInfo.java @@ -0,0 +1,76 @@ +/* + * 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.maven.annotations; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.objectweb.asm.Opcodes; + +/// Lightweight description of a method discovered during the class-scanning +/// pass. Mirrors ASM's `MethodVisitor` signature without retaining the visitor +/// itself. +/// +/// `descriptor` is the JVM signature (e.g., `(Ljava/lang/String;)V`). +/// `annotations` are keyed by their JVM descriptor. +public final class MethodInfo { + + private final String name; + private final String descriptor; + private final int access; + private final Map annotations; + + MethodInfo(String name, String descriptor, int access, Map annotations) { + this.name = name; + this.descriptor = descriptor; + this.access = access; + this.annotations = (annotations == null) + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(annotations)); + } + + public String getName() { return name; } + public String getDescriptor() { return descriptor; } + public int getAccess() { return access; } + + public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } + public boolean isStatic() { return (access & Opcodes.ACC_STATIC) != 0; } + public boolean isAbstract() { return (access & Opcodes.ACC_ABSTRACT) != 0; } + public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } + + /// Returns true when this is a `` method (constructor). + public boolean isConstructor() { return "".equals(name); } + + /// All annotations on this method, keyed by JVM descriptor (e.g. + /// `Lcom/codename1/annotations/Async$Schedule;`). + public Map getAnnotations() { return annotations; } + + /// Convenience: look up an annotation by descriptor, returning null if absent. + public AnnotationValues getAnnotation(String descriptor) { return annotations.get(descriptor); } + + @Override + public String toString() { + return name + descriptor; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessingException.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessingException.java new file mode 100644 index 0000000000..b3767e5a56 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessingException.java @@ -0,0 +1,42 @@ +/* + * 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.maven.annotations; + +/// Thrown by an `AnnotationProcessor` when it encounters a non-recoverable +/// error. Halts the build via `MojoFailureException` from the orchestrator. +/// +/// Recoverable errors (e.g. one malformed annotation among many) should be +/// reported through `ProcessorContext#error` so processing can continue and +/// surface every issue in a single build run. +public class ProcessingException extends Exception { + + private static final long serialVersionUID = 1L; + + public ProcessingException(String message) { + super(message); + } + + public ProcessingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java new file mode 100644 index 0000000000..92858ffe60 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ProcessorContext.java @@ -0,0 +1,134 @@ +/* + * 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.maven.annotations; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.maven.plugin.logging.Log; + +/// Shared state passed to every `AnnotationProcessor`. +/// +/// Exposes: +/// - A read-only **class index** of every non-synthetic class found in the +/// project's compiled output, keyed by JVM internal name. Processors use it +/// to traverse superclass chains, check interface implementations, etc. +/// - The **output class directory** so emitted bytecode lands in the same +/// tree the rest of the build references. +/// - An **error sink** (`#error`) processors call when a class fails validation. +/// Errors accumulate rather than throwing immediately, so a single run can +/// surface every offending class. +/// - A **stub source directory** in `target/generated-sources/cn1-annotations` +/// used by the GENERATE_SOURCES Mojo; the PROCESS_CLASSES path doesn't write +/// to it but the directory may exist either way. +public final class ProcessorContext { + + private final File outputClassDir; + private final File stubSourceDir; + private final Map classIndex; + private final Log log; + private final List errors = new ArrayList(); + private final Map emittedClasses = new LinkedHashMap(); + private final Map emittedStubSources = new LinkedHashMap(); + + public ProcessorContext(File outputClassDir, File stubSourceDir, + Map classIndex, Log log) { + this.outputClassDir = outputClassDir; + this.stubSourceDir = stubSourceDir; + this.classIndex = classIndex == null + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap(classIndex)); + this.log = log; + } + + /// `target/classes` for the project, or the equivalent output directory. + public File getOutputClassDir() { return outputClassDir; } + + /// `target/generated-sources/cn1-annotations` (or the configured stub dir). + /// Always present even during PROCESS_CLASSES so processors can probe for + /// previously generated stubs. + public File getStubSourceDir() { return stubSourceDir; } + + /// All non-synthetic classes from `target/classes`, keyed by internal name. + public Map getClassIndex() { return classIndex; } + + /// Looks up a class by internal name (`com/example/Foo`). Returns null when + /// the class is not in the project's compiled output (e.g. it's a JDK or + /// dependency class). + public AnnotatedClass lookup(String internalName) { return classIndex.get(internalName); } + + public Log getLog() { return log; } + + /// Reports a validation error attributed to `source`. Continues processing. + public void error(AnnotatedClass source, String message) { + errors.add(new ProcessingError(source, message)); + } + + /// Reports a global (non-class-bound) error. + public void error(String message) { + errors.add(new ProcessingError(null, message)); + } + + /// Queues a generated class for write-out. Path comes from the internal + /// name. Subsequent calls with the same name overwrite — useful for + /// processors that update existing stubs. + public void emitClass(String internalName, byte[] bytecode) { + emittedClasses.put(internalName, bytecode); + } + + /// Queues a generated Java source for write-out under the stub source + /// directory. Used by GENERATE_SOURCES phase. `internalName` follows the + /// same `com/example/Foo` convention as #emitClass. + public void emitStubSource(String internalName, String javaSource) { + emittedStubSources.put(internalName, javaSource); + } + + public boolean hasErrors() { return !errors.isEmpty(); } + public List getErrors() { return Collections.unmodifiableList(errors); } + public Map getEmittedClasses() { return Collections.unmodifiableMap(emittedClasses); } + public Map getEmittedStubSources() { return Collections.unmodifiableMap(emittedStubSources); } + + /// One validation error. + public static final class ProcessingError { + private final AnnotatedClass source; + private final String message; + + ProcessingError(AnnotatedClass source, String message) { + this.source = source; + this.message = message; + } + + public AnnotatedClass getSource() { return source; } + public String getMessage() { return message; } + + @Override + public String toString() { + if (source == null) return message; + return source.getBinaryName() + ": " + message; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java new file mode 100644 index 0000000000..e9508e26f8 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RouteAnnotationProcessor.java @@ -0,0 +1,831 @@ +/* + * 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.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// Bytecode-driven `@Route` processor. +/// +/// Scans the project's compiled classes for `@Route` annotations on Form +/// subclasses or static factory methods, validates each declaration fail-fast, +/// then generates `com.codename1.router.generated.Routes` as a Java source +/// file and compiles it on the spot via JSR 199 so the resulting `.class` +/// lands in the project's output directory and shadows the framework stub at +/// runtime. +/// +/// The generated `Routes` class implements `com.codename1.router.RouteDispatcher`, +/// registers itself with `Display` from its static `bootstrap()` method, and +/// dispatches incoming URLs by matching against the recognised patterns, +/// extracting path variables, and invoking the matching constructor / factory. +/// +/// Validation surfaces every offending class in a single build run via +/// `ProcessorContext#error`. No bytecode is written when any error is pending. +public final class RouteAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String ROUTE_DESC = "Lcom/codename1/annotations/Route;"; + public static final String ROUTES_DESC = "Lcom/codename1/annotations/Route$Routes;"; + public static final String ROUTE_PARAM_DESC = "Lcom/codename1/annotations/RouteParam;"; + + static final String FORM_INTERNAL = "com/codename1/ui/Form"; + static final String FORM_BINARY = "com.codename1.ui.Form"; + static final String STRING_BINARY = "java.lang.String"; + + /// Internal name of the generated class. Application code never references + /// it directly; the framework loads it via `Display.init()`. + static final String ROUTES_INTERNAL = "com/codename1/router/generated/Routes"; + static final String ROUTES_PACKAGE = "com.codename1.router.generated"; + static final String ROUTES_SIMPLE = "Routes"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(ROUTE_DESC); + s.add(ROUTES_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + /// Accepted routes keyed by path pattern. TreeMap so the emitted source is + /// deterministic regardless of class-scan order. + private final TreeMap accepted = new TreeMap(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + accepted.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) { + return; + } + // Two paths: class-level @Route (constructor target) and method-level + // @Route (static-factory target). A single class can hold both kinds. + if (cls.getClassAnnotation(ROUTE_DESC) != null + || cls.getClassAnnotation(ROUTES_DESC) != null) { + processClassLevel(cls, ctx); + } + for (MethodInfo m : cls.getMethods()) { + if (m.getAnnotation(ROUTE_DESC) != null || m.getAnnotation(ROUTES_DESC) != null) { + processMethodLevel(cls, m, ctx); + } + } + } + + private void processClassLevel(AnnotatedClass cls, ProcessorContext ctx) { + if (cls.isAbstract() || cls.isInterface()) { + ctx.error(cls, "@Route on a class requires a concrete Form subclass; " + + cls.getBinaryName() + " is abstract or an interface"); + return; + } + if (!extendsForm(cls, ctx)) { + ctx.error(cls, "@Route classes must extend com.codename1.ui.Form (transitively); " + + cls.getBinaryName() + " extends " + dot(cls.getSuperInternalName())); + return; + } + List annotations = collectAnnotations( + cls.getClassAnnotation(ROUTE_DESC), + cls.getClassAnnotation(ROUTES_DESC)); + + // Pick a constructor: prefer one whose parameters cover every path + // variable in the pattern via @RouteParam. + for (AnnotationValues av : annotations) { + String pattern = patternOf(av, cls, ctx); + if (pattern == null) { + continue; + } + List required = pathVarsOf(pattern); + ConstructorBinding binding = pickConstructor(cls, required, ctx); + if (binding == null) { + return; + } + register(pattern, Entry.forClass(pattern, cls.getBinaryName(), binding), cls, ctx); + } + } + + private void processMethodLevel(AnnotatedClass cls, MethodInfo method, ProcessorContext ctx) { + if (!method.isStatic() || !method.isPublic()) { + ctx.error(cls, "@Route methods must be public static; " + + cls.getBinaryName() + "#" + method.getName() + " is not"); + return; + } + if (!returnsForm(method)) { + ctx.error(cls, "@Route methods must return a Form (or a Form subtype); " + + cls.getBinaryName() + "#" + method.getName() + " returns " + + returnTypeBinary(method.getDescriptor())); + return; + } + List annotations = collectAnnotations( + method.getAnnotation(ROUTE_DESC), method.getAnnotation(ROUTES_DESC)); + for (AnnotationValues av : annotations) { + String pattern = patternOf(av, cls, ctx); + if (pattern == null) { + continue; + } + List required = pathVarsOf(pattern); + MethodBinding binding = bindMethod(cls, method, required, ctx); + if (binding == null) { + return; + } + register(pattern, Entry.forMethod(pattern, cls.getBinaryName(), method.getName(), binding), + cls, ctx); + } + } + + private void register(String pattern, Entry entry, AnnotatedClass cls, ProcessorContext ctx) { + Entry prev = accepted.get(pattern); + if (prev != null) { + ctx.error(cls, "duplicate @Route pattern \"" + pattern + "\": already declared on " + + prev.targetDescription()); + return; + } + accepted.put(pattern, entry); + } + + // ------------------------------------------------------------------------ + // Output + // ------------------------------------------------------------------------ + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) { + return; + } + if (accepted.isEmpty()) { + // No project-declared routes: do not emit Routes at all. Display + // init() looks the class up reflectively and silently no-ops when + // it's absent. + return; + } + String source = generateRoutesSource(new ArrayList(accepted.values())); + File outDir = ctx.getOutputClassDir(); + // Find the classpath for compilation (framework jar provides Display + + // RouteDispatcher; the project's own classes provide @Route targets). + List classpath = buildCompileClasspath(outDir); + try { + Map srcs = JavaSourceCompiler.singleSource( + ROUTES_PACKAGE + "." + ROUTES_SIMPLE, source); + JavaSourceCompiler.compile(srcs, outDir, classpath); + } catch (IOException e) { + throw new ProcessingException( + "Failed to compile generated " + ROUTES_PACKAGE + "." + ROUTES_SIMPLE + + ": " + e.getMessage(), e); + } + ctx.getLog().info("cn1: generated " + ROUTES_PACKAGE + "." + ROUTES_SIMPLE + + " with " + accepted.size() + " route(s)"); + } + + private List buildCompileClasspath(File outDir) { + List cp = new ArrayList(); + // The project's own compiled classes — needed for @Route target types. + cp.add(outDir); + // Inherit whatever javac defaults to; the surrounding plugin invocation + // already supplies the project's compile classpath via java.class.path, + // which JavaSourceCompiler picks up. + return cp; + } + + // ------------------------------------------------------------------------ + // Source generation + // ------------------------------------------------------------------------ + + private static String generateRoutesSource(List routes) { + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by the Codename One Maven plugin from @Route annotations.\n"); + sb.append("// Do not edit -- regenerated on every build.\n"); + sb.append("package ").append(ROUTES_PACKAGE).append(";\n\n"); + sb.append("import com.codename1.router.Navigation;\n"); + sb.append("import com.codename1.router.RouteDispatcher;\n"); + sb.append("import com.codename1.ui.Form;\n\n"); + sb.append("public final class ").append(ROUTES_SIMPLE) + .append(" implements RouteDispatcher {\n\n"); + // Self-registering constructor: the application stub the builders + // generate calls `new Routes()` directly before Display.init(), so + // we install the dispatcher here. Direct symbol reference -- not + // Class.forName -- so obfuscation rewrites the call site and the + // class together and the binding survives in shipped builds. + sb.append(" public Routes() {\n"); + sb.append(" Navigation.setDispatcher(this);\n"); + sb.append(" }\n\n"); + sb.append(" @Override\n"); + sb.append(" public Form dispatch(String url) {\n"); + sb.append(" if (url == null || url.length() == 0) {\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append(" String path = extractPath(url);\n"); + sb.append(" String[] segs = splitPath(path);\n"); + sb.append(" java.util.Map q = null;\n"); + // Emit branches most-specific first so a literal route wins over a + // catch-all that also matches. + List ordered = new ArrayList(routes); + Collections.sort(ordered, new Comparator() { + @Override + public int compare(Entry a, Entry b) { + int diff = specificity(b.pattern) - specificity(a.pattern); + if (diff != 0) { + return diff; + } + return a.pattern.compareTo(b.pattern); + } + }); + for (Entry e : ordered) { + emitRouteBranch(sb, e); + } + sb.append(" return null;\n"); + sb.append(" }\n\n"); + emitHelpers(sb); + sb.append("}\n"); + return sb.toString(); + } + + private static void emitRouteBranch(StringBuilder sb, Entry e) { + String[] segs = patternSegments(e.pattern); + boolean catchAll = segs.length > 0 && "**".equals(segs[segs.length - 1]); + sb.append(" // ").append(e.pattern).append(" -> ").append(e.targetDescription()).append('\n'); + // Length check + if (catchAll) { + sb.append(" if (segs.length >= ").append(segs.length - 1).append(") {\n"); + } else { + sb.append(" if (segs.length == ").append(segs.length).append(") {\n"); + } + // Per-segment matching + sb.append(" boolean match = true;\n"); + for (int i = 0; i < segs.length; i++) { + String s = segs[i]; + if (s.startsWith(":") || "*".equals(s) || "**".equals(s)) { + continue; + } + sb.append(" match = match && \"").append(escape(s)).append("\".equals(segs[") + .append(i).append("]);\n"); + } + sb.append(" if (match) {\n"); + // Bind path vars + Map varToExpr = new LinkedHashMap(); + for (int i = 0; i < segs.length; i++) { + String s = segs[i]; + if (s.startsWith(":")) { + varToExpr.put(s.substring(1), "segs[" + i + "]"); + } else if ("*".equals(s)) { + varToExpr.put("*", "segs[" + i + "]"); + } else if ("**".equals(s)) { + varToExpr.put("*", "joinFrom(segs, " + i + ")"); + } + } + // Pull query map for non-path bindings (lazy: only parse when matched). + sb.append(" if (q == null) { q = parseQuery(url); }\n"); + // Build constructor / static factory call and return -- first match wins. + sb.append(" return ").append(e.buildExpression(varToExpr)).append(";\n"); + sb.append(" }\n"); + sb.append(" }\n"); + } + + /// Specificity score used to order route branches in the generated + /// dispatch method: literal segments outscore named params, params + /// outscore catch-all wildcards. Mirrors the established ant-pattern + /// scoring so the most specific route wins when several patterns match + /// the same URL. + private static int specificity(String pattern) { + int score = 0; + for (String s : patternSegments(pattern)) { + if ("**".equals(s)) { + score -= 100; + } else if ("*".equals(s) || (s.length() > 0 && s.charAt(0) == ':')) { + score += 1; + } else { + score += 10; + } + } + return score; + } + + private static void emitHelpers(StringBuilder sb) { + sb.append(" private static String extractPath(String url) {\n"); + sb.append(" int h = url.indexOf('#');\n"); + sb.append(" if (h >= 0) { url = url.substring(0, h); }\n"); + sb.append(" int q = url.indexOf('?');\n"); + sb.append(" if (q >= 0) { url = url.substring(0, q); }\n"); + sb.append(" int s = url.indexOf(\"://\");\n"); + sb.append(" if (s >= 0) {\n"); + sb.append(" int slash = url.indexOf('/', s + 3);\n"); + sb.append(" return slash < 0 ? \"/\" : url.substring(slash);\n"); + sb.append(" }\n"); + sb.append(" int colon = url.indexOf(':');\n"); + sb.append(" if (colon > 0) {\n"); + sb.append(" String tail = url.substring(colon + 1);\n"); + sb.append(" return tail.length() == 0 ? \"/\"\n"); + sb.append(" : (tail.charAt(0) == '/' ? tail : \"/\" + tail);\n"); + sb.append(" }\n"); + sb.append(" return url.length() == 0 || url.charAt(0) != '/' ? \"/\" + url : url;\n"); + sb.append(" }\n\n"); + sb.append(" private static String[] splitPath(String path) {\n"); + sb.append(" if (path == null || path.length() == 0 || \"/\".equals(path)) {\n"); + sb.append(" return new String[0];\n"); + sb.append(" }\n"); + sb.append(" String p = path.charAt(0) == '/' ? path.substring(1) : path;\n"); + sb.append(" if (p.length() > 0 && p.charAt(p.length() - 1) == '/') {\n"); + sb.append(" p = p.substring(0, p.length() - 1);\n"); + sb.append(" }\n"); + sb.append(" if (p.length() == 0) { return new String[0]; }\n"); + sb.append(" java.util.ArrayList out = new java.util.ArrayList();\n"); + sb.append(" int start = 0;\n"); + sb.append(" for (int i = 0; i < p.length(); i++) {\n"); + sb.append(" if (p.charAt(i) == '/') {\n"); + sb.append(" out.add(decode(p.substring(start, i)));\n"); + sb.append(" start = i + 1;\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" out.add(decode(p.substring(start)));\n"); + sb.append(" return out.toArray(new String[out.size()]);\n"); + sb.append(" }\n\n"); + sb.append(" private static String joinFrom(String[] segs, int from) {\n"); + sb.append(" if (from >= segs.length) { return \"\"; }\n"); + sb.append(" StringBuilder sb = new StringBuilder();\n"); + sb.append(" for (int i = from; i < segs.length; i++) {\n"); + sb.append(" if (i > from) { sb.append('/'); }\n"); + sb.append(" sb.append(segs[i]);\n"); + sb.append(" }\n"); + sb.append(" return sb.toString();\n"); + sb.append(" }\n\n"); + sb.append(" private static java.util.Map parseQuery(String url) {\n"); + sb.append(" java.util.LinkedHashMap out = new java.util.LinkedHashMap();\n"); + sb.append(" int q = url.indexOf('?');\n"); + sb.append(" if (q < 0) { return out; }\n"); + sb.append(" int hash = url.indexOf('#', q);\n"); + sb.append(" String query = hash < 0 ? url.substring(q + 1) : url.substring(q + 1, hash);\n"); + sb.append(" int start = 0;\n"); + sb.append(" for (int i = 0; i <= query.length(); i++) {\n"); + sb.append(" if (i == query.length() || query.charAt(i) == '&') {\n"); + sb.append(" if (i > start) {\n"); + sb.append(" String pair = query.substring(start, i);\n"); + sb.append(" int eq = pair.indexOf('=');\n"); + sb.append(" if (eq < 0) {\n"); + sb.append(" out.put(decode(pair), \"\");\n"); + sb.append(" } else {\n"); + sb.append(" out.put(decode(pair.substring(0, eq)), decode(pair.substring(eq + 1)));\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" start = i + 1;\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" return out;\n"); + sb.append(" }\n\n"); + sb.append(" private static String decode(String s) {\n"); + sb.append(" try { return com.codename1.io.Util.decode(s, \"UTF-8\", false); }\n"); + sb.append(" catch (Throwable t) { return s; }\n"); + sb.append(" }\n\n"); + sb.append(" private static String throwMissing(String name) {\n"); + sb.append(" throw new IllegalArgumentException(\n"); + sb.append(" \"deep link is missing required @RouteParam \\\"\" + name + \"\\\"\");\n"); + sb.append(" }\n"); + } + + private static String[] patternSegments(String pattern) { + if (pattern == null || pattern.length() == 0 || "/".equals(pattern)) { + return new String[0]; + } + String p = pattern.charAt(0) == '/' ? pattern.substring(1) : pattern; + if (p.length() > 0 && p.charAt(p.length() - 1) == '/') { + p = p.substring(0, p.length() - 1); + } + return p.length() == 0 ? new String[0] : p.split("/"); + } + + // ------------------------------------------------------------------------ + // Validation helpers + // ------------------------------------------------------------------------ + + private static List collectAnnotations(AnnotationValues single, AnnotationValues container) { + List out = new ArrayList(); + if (single != null) { + out.add(single); + } + if (container != null) { + Object value = container.get("value"); + if (value instanceof List) { + for (Object item : (List) value) { + if (item instanceof AnnotationValues) { + out.add((AnnotationValues) item); + } + } + } + } + return out; + } + + private static String patternOf(AnnotationValues av, AnnotatedClass cls, ProcessorContext ctx) { + String pattern = av.getString("value"); + if (pattern == null || pattern.length() == 0) { + ctx.error(cls, "@Route value is required and must be a non-empty path"); + return null; + } + if (pattern.charAt(0) != '/') { + ctx.error(cls, "@Route value must start with '/'; got: \"" + pattern + "\""); + return null; + } + return pattern; + } + + private static List pathVarsOf(String pattern) { + List out = new ArrayList(); + for (String s : patternSegments(pattern)) { + if (s.startsWith(":")) { + out.add(s.substring(1)); + } else if ("*".equals(s) || "**".equals(s)) { + out.add("*"); + } + } + return out; + } + + private static boolean extendsForm(AnnotatedClass cls, ProcessorContext ctx) { + if (cls == null) { + return false; + } + if (FORM_INTERNAL.equals(cls.getInternalName())) { + return true; + } + String parent = cls.getSuperInternalName(); + while (parent != null) { + if (FORM_INTERNAL.equals(parent)) { + return true; + } + AnnotatedClass parentCls = ctx.lookup(parent); + if (parentCls == null) { + // Left the project (cn1-core, JDK, etc.). Be permissive for + // anything in com/codename1/ui that ends in Form/Dialog. + return parent.startsWith("com/codename1/ui/") + && (parent.endsWith("Form") || parent.endsWith("Dialog")); + } + parent = parentCls.getSuperInternalName(); + } + return false; + } + + private static boolean returnsForm(MethodInfo method) { + String desc = method.getDescriptor(); + int close = desc.lastIndexOf(')'); + if (close < 0 || close + 1 >= desc.length()) { + return false; + } + String ret = desc.substring(close + 1); + if (!ret.startsWith("L") || !ret.endsWith(";")) { + return false; + } + String internal = ret.substring(1, ret.length() - 1); + // Permissive: any class whose internal name ends with Form is accepted. + // The compiled call site verifies type-correctness at javac time. + return internal.equals(FORM_INTERNAL) || internal.endsWith("Form") + || internal.endsWith("Dialog"); + } + + private static String returnTypeBinary(String desc) { + int close = desc.lastIndexOf(')'); + if (close < 0 || close + 1 >= desc.length()) { + return "?"; + } + String ret = desc.substring(close + 1); + if (ret.startsWith("L") && ret.endsWith(";")) { + return ret.substring(1, ret.length() - 1).replace('/', '.'); + } + return ret; + } + + // ------------------------------------------------------------------------ + // Parameter binding: read @RouteParam from constructor / method parameters + // ------------------------------------------------------------------------ + + private ConstructorBinding pickConstructor(AnnotatedClass cls, List requiredPathVars, + ProcessorContext ctx) { + ConstructorBinding best = null; + int bestScore = -1; + for (MethodInfo m : cls.getMethods()) { + if (!m.isConstructor() || !m.isPublic()) { + continue; + } + ParamBinding[] params = parameterBindings(cls, m, ctx); + if (params == null) { + continue; + } + // Score: covers all required path vars + parameter count proximity. + if (!covers(params, requiredPathVars)) { + continue; + } + int score = params.length * 10 + coverageScore(params, requiredPathVars); + if (score > bestScore) { + bestScore = score; + best = new ConstructorBinding(m.getDescriptor(), params); + } + } + if (best == null) { + ctx.error(cls, "@Route class " + cls.getBinaryName() + + " has no public constructor that binds every path variable via @RouteParam"); + } + return best; + } + + private MethodBinding bindMethod(AnnotatedClass cls, MethodInfo method, + List requiredPathVars, ProcessorContext ctx) { + ParamBinding[] params = parameterBindings(cls, method, ctx); + if (params == null) { + return null; + } + if (!covers(params, requiredPathVars)) { + ctx.error(cls, "@Route method " + cls.getBinaryName() + "#" + method.getName() + + " does not declare a @RouteParam for every path variable in its pattern"); + return null; + } + return new MethodBinding(method.getName(), method.getDescriptor(), params); + } + + /// Reads the byte-code parameter annotations for `method`, mapping each + /// parameter to its `@RouteParam` value when present. Returns null if any + /// parameter is missing the annotation (so the caller knows to error). + private ParamBinding[] parameterBindings(AnnotatedClass owningClass, MethodInfo method, + ProcessorContext ctx) { + String desc = method.getDescriptor(); + List paramTypes = paramTypesOf(desc); + // Re-parse the class file to extract per-parameter annotations -- we + // don't keep them in the lightweight MethodInfo. + Map meta; + try { + meta = readParameterAnnotations(owningClass, method); + } catch (IOException e) { + ctx.error(owningClass, "could not read parameter annotations: " + e.getMessage()); + return null; + } + boolean anyMissing = false; + ParamBinding[] out = new ParamBinding[paramTypes.size()]; + for (int i = 0; i < out.length; i++) { + ParamMeta m = meta.get(i); + if (m == null) { + ctx.error(owningClass, "@Route target " + owningClass.getBinaryName() + "#" + + method.getName() + " parameter #" + i + + " has no @RouteParam binding; every parameter must be annotated"); + anyMissing = true; + continue; + } + if (!STRING_BINARY.equals(paramTypes.get(i))) { + ctx.error(owningClass, "@RouteParam(\"" + m.name + "\") on " + + owningClass.getBinaryName() + "#" + method.getName() + + " parameter #" + i + " must be of type java.lang.String (was " + + paramTypes.get(i) + ")"); + anyMissing = true; + continue; + } + out[i] = new ParamBinding(m.name, m.required); + } + return anyMissing ? null : out; + } + + private static List paramTypesOf(String desc) { + List out = new ArrayList(); + int i = desc.indexOf('(') + 1; + int end = desc.indexOf(')'); + while (i < end) { + char c = desc.charAt(i); + if (c == 'L') { + int semi = desc.indexOf(';', i); + out.add(desc.substring(i + 1, semi).replace('/', '.')); + i = semi + 1; + } else if (c == '[') { + int j = i; + while (desc.charAt(j) == '[') { + j++; + } + if (desc.charAt(j) == 'L') { + j = desc.indexOf(';', j); + } + out.add(desc.substring(i, j + 1)); + i = j + 1; + } else { + out.add(String.valueOf(c)); + i++; + } + } + return out; + } + + private Map readParameterAnnotations(AnnotatedClass cls, MethodInfo method) + throws IOException { + final Map out = new HashMap(); + File file = cls.getClassFile(); + if (file == null) { + return out; + } + InputStream in = Files.newInputStream(file.toPath()); + try { + ClassReader reader = new ClassReader(in); + reader.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + if (!name.equals(method.getName()) || !descriptor.equals(method.getDescriptor())) { + return null; + } + return new MethodVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.AnnotationVisitor visitParameterAnnotation( + final int parameter, String desc, boolean visible) { + if (!ROUTE_PARAM_DESC.equals(desc)) { + return null; + } + final ParamMeta meta = new ParamMeta(); + out.put(parameter, meta); + return new org.objectweb.asm.AnnotationVisitor(Opcodes.ASM9) { + @Override + public void visit(String n, Object v) { + if ("value".equals(n) && v instanceof String) { + meta.name = (String) v; + } else if ("required".equals(n) && v instanceof Boolean) { + meta.required = (Boolean) v; + } + } + }; + } + }; + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + } finally { + in.close(); + } + return out; + } + + private static boolean covers(ParamBinding[] params, List requiredPathVars) { + Set bound = new LinkedHashSet(); + for (ParamBinding p : params) { + bound.add(p.name); + } + for (String v : requiredPathVars) { + if (!bound.contains(v)) { + return false; + } + } + return true; + } + + private static int coverageScore(ParamBinding[] params, List requiredPathVars) { + int score = 0; + for (ParamBinding p : params) { + if (requiredPathVars.contains(p.name)) { + score++; + } + } + return score; + } + + private static String dot(String internalName) { + return internalName == null ? "null" : internalName.replace('/', '.'); + } + + private static String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + // ------------------------------------------------------------------------ + // Model + // ------------------------------------------------------------------------ + + private static final class ParamMeta { + String name; + boolean required = true; + } + + static final class ParamBinding { + final String name; + final boolean required; + ParamBinding(String name, boolean required) { + this.name = name; + this.required = required; + } + String paramExpression(Map pathExpressions) { + String fromPath = pathExpressions.get(name); + if (fromPath != null) { + return fromPath; + } + // Fall back to query string. + String def = required + ? "throwMissing(\"" + name + "\")" + : "null"; + return "q.containsKey(\"" + name + "\") ? q.get(\"" + name + "\") : " + def; + } + } + + static final class ConstructorBinding { + final String descriptor; + final ParamBinding[] params; + ConstructorBinding(String descriptor, ParamBinding[] params) { + this.descriptor = descriptor; + this.params = params; + } + } + + static final class MethodBinding { + final String name; + final String descriptor; + final ParamBinding[] params; + MethodBinding(String name, String descriptor, ParamBinding[] params) { + this.name = name; + this.descriptor = descriptor; + this.params = params; + } + } + + static final class Entry { + final String pattern; + final String targetClassBinary; + final String methodName; // null for class-level + final Object binding; + + private Entry(String pattern, String targetClassBinary, String methodName, Object binding) { + this.pattern = pattern; + this.targetClassBinary = targetClassBinary; + this.methodName = methodName; + this.binding = binding; + } + + static Entry forClass(String pattern, String targetClassBinary, ConstructorBinding binding) { + return new Entry(pattern, targetClassBinary, null, binding); + } + + static Entry forMethod(String pattern, String targetClassBinary, String methodName, + MethodBinding binding) { + return new Entry(pattern, targetClassBinary, methodName, binding); + } + + String targetDescription() { + return methodName == null ? targetClassBinary + : targetClassBinary + "#" + methodName; + } + + String buildExpression(Map pathExpressions) { + ParamBinding[] params; + if (binding instanceof ConstructorBinding) { + params = ((ConstructorBinding) binding).params; + } else { + params = ((MethodBinding) binding).params; + } + StringBuilder args = new StringBuilder(); + for (int i = 0; i < params.length; i++) { + if (i > 0) { + args.append(", "); + } + args.append(params[i].paramExpression(pathExpressions)); + } + if (methodName == null) { + return "new " + targetClassBinary + "(" + args + ")"; + } + return targetClassBinary + "." + methodName + "(" + args + ")"; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AasaBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AasaBuilder.java new file mode 100644 index 0000000000..26d0c382d9 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AasaBuilder.java @@ -0,0 +1,192 @@ +/* + * 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.maven.routing; + +import java.util.ArrayList; +import java.util.List; + +/// Generates an `apple-app-site-association` (AASA) JSON payload for iOS +/// Universal Links. The output is intended to be hosted at +/// `https://your.domain/.well-known/apple-app-site-association` (and at the root +/// `/apple-app-site-association` for older OS versions), served over HTTPS with +/// `Content-Type: application/json` and no redirects. +/// +/// Apple validates the file against the app's entitlements at install time. +/// Apps must have the **Associated Domains** capability enabled with an entry +/// of the form `applinks:your.domain`. +/// +/// #### Example +/// +/// ```java +/// String json = new AasaBuilder() +/// .appId("ABCD1234.com.example.app") +/// .addPath("/users/*") +/// .addPath("NOT /admin/*") // exclude pattern +/// .addPath("/share/?id=*") +/// .build(); +/// // Write `json` to https://example.com/.well-known/apple-app-site-association +/// ``` +/// +/// #### Reference +/// +/// Apple's documentation: +public final class AasaBuilder { + + private final List apps = new ArrayList(); + private App pending; + + /// Begins a new app entry. Required: bundle prefix (10-character team ID) + + /// bundle identifier, joined by a period. Repeat the call to add multiple + /// apps that share the same domain. + public AasaBuilder appId(String teamIdAndBundleId) { + if (teamIdAndBundleId == null || teamIdAndBundleId.length() == 0) { + throw new IllegalArgumentException("appId required"); + } + pending = new App(teamIdAndBundleId); + apps.add(pending); + return this; + } + + /// Adds a path pattern. Prefix with `NOT ` to exclude. Supports `*` wildcards + /// (single segment) and `?` query-match notation per Apple's syntax. + public AasaBuilder addPath(String pattern) { + if (pending == null) { + throw new IllegalStateException("call appId(...) before addPath(...)"); + } + if (pattern == null || pattern.length() == 0) { + return this; + } + pending.paths.add(pattern); + return this; + } + + /// Convenience: convert a `Router`-style pattern (`/users/:id`) to AASA's + /// wildcard syntax (`/users/*`) and add it. Catch-all `**` becomes `*`. + public AasaBuilder addRouterPattern(String routerPattern) { + return addPath(toAasaPath(routerPattern)); + } + + /// Builds the JSON string. UTF-8 encoded, formatted for readability. + public String build() { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"applinks\": {\n"); + sb.append(" \"details\": [\n"); + for (int i = 0; i < apps.size(); i++) { + App a = apps.get(i); + sb.append(" {\n"); + sb.append(" \"appIDs\": [\"").append(jsonEscape(a.appId)).append("\"],\n"); + sb.append(" \"components\": [\n"); + for (int j = 0; j < a.paths.size(); j++) { + String p = a.paths.get(j); + sb.append(" ").append(toComponent(p)); + if (j < a.paths.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + sb.append(" ]\n"); + sb.append(" }"); + if (i < apps.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + sb.append(" ]\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + static String toAasaPath(String routerPattern) { + if (routerPattern == null) { + return "/*"; + } + StringBuilder sb = new StringBuilder(); + int i = 0; + if (routerPattern.length() == 0 || routerPattern.charAt(0) != '/') { + sb.append('/'); + } + while (i < routerPattern.length()) { + char c = routerPattern.charAt(i); + if (c == ':') { + // skip :name token + sb.append('*'); + while (i < routerPattern.length() && routerPattern.charAt(i) != '/') { + i++; + } + } else if (c == '*') { + sb.append('*'); + while (i < routerPattern.length() && routerPattern.charAt(i) == '*') { + i++; + } + } else { + sb.append(c); + i++; + } + } + return sb.toString(); + } + + private static String toComponent(String pattern) { + boolean exclude = false; + String p = pattern; + if (p.startsWith("NOT ")) { + exclude = true; + p = p.substring(4); + } + StringBuilder sb = new StringBuilder("{ \"/\": \"").append(jsonEscape(p)).append("\""); + if (exclude) { + sb.append(", \"exclude\": true"); + } + sb.append(" }"); + return sb.toString(); + } + + private static String jsonEscape(String s) { + StringBuilder sb = new StringBuilder(s.length() + 2); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\t') { + sb.append("\\t"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static final class App { + final String appId; + final List paths = new ArrayList(); + App(String id) { + this.appId = id; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AssetLinksBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AssetLinksBuilder.java new file mode 100644 index 0000000000..3ba0198422 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/routing/AssetLinksBuilder.java @@ -0,0 +1,150 @@ +/* + * 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.maven.routing; + +import java.util.ArrayList; +import java.util.List; + +/// Generates an `assetlinks.json` payload for Android App Links. The output is +/// intended to be hosted at `https://your.domain/.well-known/assetlinks.json`, +/// served over HTTPS with `Content-Type: application/json` and no redirects. +/// +/// The Android system fetches this file at app install time and grants the app +/// the right to handle web intents for the domain automatically. Without it, +/// Android falls back to disambiguation chooser even if the app declares the +/// intent filter. +/// +/// #### SHA-256 cert fingerprint +/// +/// You can extract the fingerprint from your release keystore with: +/// +/// ```sh +/// keytool -list -v -keystore your.keystore -alias your-alias | grep "SHA256:" +/// ``` +/// +/// The fingerprint must be supplied in colon-separated hex form (the format +/// `keytool` emits). +/// +/// #### Example +/// +/// ```java +/// String json = new AssetLinksBuilder() +/// .addApp("com.example.app", +/// "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5") +/// .build(); +/// ``` +/// +/// #### Reference +/// +/// Google's documentation: +public final class AssetLinksBuilder { + + private final List entries = new ArrayList(); + + /// Adds an app entry. `packageName` is the application id from + /// `AndroidManifest.xml`. `sha256Fingerprint` is the SHA-256 of the signing + /// certificate, colon-separated hex. + /// + /// To support multiple build flavors (debug + release), call this method + /// multiple times -- assetlinks.json supports an array of entries. + public AssetLinksBuilder addApp(String packageName, String sha256Fingerprint) { + if (packageName == null || packageName.length() == 0) { + throw new IllegalArgumentException("packageName required"); + } + if (sha256Fingerprint == null || sha256Fingerprint.length() == 0) { + throw new IllegalArgumentException("sha256Fingerprint required"); + } + Entry e = new Entry(packageName); + e.fingerprints.add(sha256Fingerprint); + entries.add(e); + return this; + } + + /// Adds an additional fingerprint to the most recently added app entry -- + /// useful when both Play App Signing's upload cert and your release cert + /// should be verified. + public AssetLinksBuilder addFingerprint(String sha256Fingerprint) { + if (entries.isEmpty()) { + throw new IllegalStateException("call addApp(...) before addFingerprint(...)"); + } + entries.get(entries.size() - 1).fingerprints.add(sha256Fingerprint); + return this; + } + + /// Builds the JSON string. UTF-8 encoded, formatted for readability. + public String build() { + StringBuilder sb = new StringBuilder(); + sb.append("[\n"); + for (int i = 0; i < entries.size(); i++) { + Entry e = entries.get(i); + sb.append(" {\n"); + sb.append(" \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n"); + sb.append(" \"target\": {\n"); + sb.append(" \"namespace\": \"android_app\",\n"); + sb.append(" \"package_name\": \"").append(jsonEscape(e.pkg)).append("\",\n"); + sb.append(" \"sha256_cert_fingerprints\": ["); + for (int j = 0; j < e.fingerprints.size(); j++) { + if (j > 0) { + sb.append(", "); + } + sb.append('"').append(jsonEscape(e.fingerprints.get(j))).append('"'); + } + sb.append("]\n"); + sb.append(" }\n"); + sb.append(" }"); + if (i < entries.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + sb.append("]\n"); + return sb.toString(); + } + + private static String jsonEscape(String s) { + StringBuilder sb = new StringBuilder(s.length() + 2); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\t') { + sb.append("\\t"); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static final class Entry { + final String pkg; + final List fingerprints = new ArrayList(); + Entry(String p) { + this.pkg = p; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor new file mode 100644 index 0000000000..06a4f87ed3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor @@ -0,0 +1 @@ +com.codename1.maven.processors.RouteAnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java new file mode 100644 index 0000000000..5627601705 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/annotations/ClassScannerTest.java @@ -0,0 +1,113 @@ +/* + * 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.maven.annotations; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ClassScannerTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void scansSingleAnnotatedClass() throws Exception { + File out = tmp.newFolder("classes"); + String src = "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.ui.Form;\n" + + "@Route(\"/x\")\n" + + "public class Foo extends Form {\n" + + " public Foo() {}\n" + + "}\n"; + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Foo", src), + out, + Arrays.asList(testClassesDir())); + + Map index = ClassScanner.scan(out); + assertEquals(1, index.size()); + AnnotatedClass cls = index.get("com/example/Foo"); + assertNotNull(cls); + assertEquals("com/codename1/ui/Form", cls.getSuperInternalName()); + AnnotationValues r = cls.getClassAnnotation("Lcom/codename1/annotations/Route;"); + assertNotNull("@Route should have been captured", r); + assertEquals("/x", r.getString("value")); + } + + @Test + public void capturesAnnotationOnContainerForm() throws Exception { + File out = tmp.newFolder("classes"); + String src = "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.ui.Form;\n" + + "@Route.Routes({@Route(\"/a\"), @Route(\"/b\")})\n" + + "public class Bar extends Form {\n" + + " public Bar() {}\n" + + "}\n"; + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bar", src), + out, + Arrays.asList(testClassesDir())); + + Map index = ClassScanner.scan(out); + AnnotatedClass cls = index.get("com/example/Bar"); + assertNotNull(cls); + AnnotationValues container = cls.getClassAnnotation("Lcom/codename1/annotations/Route$Routes;"); + assertNotNull(container); + Object value = container.get("value"); + assertTrue("container value must be a list, got " + (value == null ? "null" : value.getClass()), + value instanceof java.util.List); + java.util.List items = (java.util.List) value; + assertEquals(2, items.size()); + } + + @Test + public void scanEmptyDirReturnsEmpty() throws Exception { + File empty = tmp.newFolder("empty"); + assertTrue(ClassScanner.scan(empty).isEmpty()); + } + + @Test + public void scanNullRootReturnsEmpty() throws Exception { + assertTrue(ClassScanner.scan(null).isEmpty()); + } + + /// Returns the plugin's own target/test-classes directory so compiled + /// fixtures can resolve the @Route + Form + Router stubs. + private static File testClassesDir() throws Exception { + java.net.URL url = ClassScannerTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java new file mode 100644 index 0000000000..6aae04565f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RouteAnnotationProcessorTest.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// End-to-end test for `RouteAnnotationProcessor`. Compiles `@Route`-annotated +/// fixtures against the real `cn1-core` types on the plugin's classpath, runs +/// the processor, and verifies the structure of the generated `Routes` class +/// by reading its bytecode with ASM. +/// +/// Bytecode inspection rather than runtime invocation: the processor itself +/// invokes `javac` (so a malformed generated source fails the build at +/// process-classes time), and reading the .class file we just wrote sidesteps +/// the JDK-8-on-Linux classloader-visibility issues that surface when a +/// child URL classloader tries to share static state with classes loaded +/// from the surefire classpath. +public class RouteAnnotationProcessorTest { + + private static final String ROUTES_INTERNAL = "com/codename1/router/generated/Routes"; + private static final String ROUTES_PATH = ROUTES_INTERNAL + ".class"; + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void classLevelRouteWithPathVariableProducesRoutesClass() throws Exception { + File classes = compileFixtures( + "com.example.Profile", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.annotations.RouteParam;\n" + + "import com.codename1.ui.Form;\n" + + "@Route(\"/users/:id\")\n" + + "public class Profile extends Form {\n" + + " public final String boundId;\n" + + " public Profile(@RouteParam(\"id\") String id) {\n" + + " this.boundId = id;\n" + + " }\n" + + "}\n"); + runProcessorOrFail(classes); + + RoutesIntrospection rx = readRoutes(classes); + assertTrue("Routes constructor should install via Navigation.setDispatcher", + rx.bootstrapInstallsViaNavigation); + assertTrue("dispatch should return Form", + rx.dispatchReturnsForm); + assertTrue("dispatch should construct com.example.Profile for the route", + rx.instantiates("com/example/Profile")); + } + + @Test + public void methodLevelRouteFactoryProducesStaticInvoke() throws Exception { + File classes = compileFixtures( + "com.example.AppRoutes", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.annotations.RouteParam;\n" + + "import com.codename1.ui.Form;\n" + + "public class AppRoutes {\n" + + " @Route(\"/home\")\n" + + " public static Form home() { return new Form(); }\n" + + " @Route(\"/users/:id\")\n" + + " public static Form profile(@RouteParam(\"id\") String id) {\n" + + " return new Form();\n" + + " }\n" + + "}\n"); + runProcessorOrFail(classes); + + RoutesIntrospection rx = readRoutes(classes); + assertTrue(rx.bootstrapInstallsViaNavigation); + assertTrue("dispatch should invoke com.example.AppRoutes.home", + rx.invokesStatic("com/example/AppRoutes", "home")); + assertTrue("dispatch should invoke com.example.AppRoutes.profile", + rx.invokesStatic("com/example/AppRoutes", "profile")); + } + + @Test + public void rejectsClassMissingRouteParamForPathVariable() throws Exception { + File classes = compileFixtures( + "com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.ui.Form;\n" + + "@Route(\"/users/:id\")\n" + + "public class Bad extends Form {\n" + + " public Bad(String id) { }\n" + + "}\n"); + ProcessorContext ctx = runProcessor(classes); + assertTrue("constructor parameter without @RouteParam must fail", + ctx.hasErrors()); + assertFalse(new File(classes, ROUTES_PATH).exists()); + } + + @Test + public void rejectsNonFormClass() throws Exception { + File classes = compileFixtures( + "com.example.NotForm", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "@Route(\"/x\")\n" + + "public class NotForm {\n" + + " public NotForm() {}\n" + + "}\n"); + ProcessorContext ctx = runProcessor(classes); + assertTrue("@Route on a non-Form class must fail", ctx.hasErrors()); + } + + @Test + public void rejectsEmptyPattern() throws Exception { + File classes = compileFixtures( + "com.example.Empty", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.ui.Form;\n" + + "@Route(\"\")\n" + + "public class Empty extends Form { public Empty() {} }\n"); + ProcessorContext ctx = runProcessor(classes); + assertTrue(ctx.hasErrors()); + } + + @Test + public void rejectsPatternMissingLeadingSlash() throws Exception { + File classes = compileFixtures( + "com.example.Home", + "package com.example;\n" + + "import com.codename1.annotations.Route;\n" + + "import com.codename1.ui.Form;\n" + + "@Route(\"home\")\n" + + "public class Home extends Form { public Home() {} }\n"); + ProcessorContext ctx = runProcessor(classes); + assertTrue(ctx.hasErrors()); + } + + @Test + public void rejectsDuplicatePatternAcrossClasses() throws Exception { + Map srcs = new HashMap(); + srcs.put("com.example.A", + "package com.example; import com.codename1.annotations.Route; import com.codename1.ui.Form;\n" + + "@Route(\"/dup\") public class A extends Form { public A() {} }\n"); + srcs.put("com.example.B", + "package com.example; import com.codename1.annotations.Route; import com.codename1.ui.Form;\n" + + "@Route(\"/dup\") public class B extends Form { public B() {} }\n"); + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile(srcs, classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue(ctx.hasErrors()); + boolean mentionsDup = false; + for (ProcessorContext.ProcessingError e : ctx.getErrors()) { + if (e.getMessage().contains("duplicate @Route pattern")) { + mentionsDup = true; + break; + } + } + assertTrue(mentionsDup); + } + + // ------------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------------ + + private File compileFixtures(String fqn, String source) throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource(fqn, source), + classes, + Arrays.asList(testClassesDir())); + return classes; + } + + private void runProcessorOrFail(File classesDir) throws Exception { + ProcessorContext ctx = runProcessor(classesDir, /*expectNoErrors*/ true); + assertTrue("processor should write the Routes class to " + ROUTES_PATH, + new File(classesDir, ROUTES_PATH).exists()); + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + return runProcessor(classesDir, /*expectNoErrors*/ false); + } + + private ProcessorContext runProcessor(File classesDir, boolean expectNoErrors) throws Exception { + Map index = ClassScanner.scan(classesDir); + RouteAnnotationProcessor proc = new RouteAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + proc.processClass(cls, ctx); + } + proc.finish(ctx); + if (expectNoErrors && ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("unexpected processor errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) { + sb.append(" ").append(e).append('\n'); + } + fail(sb.toString()); + } + return ctx; + } + + private static File testClassesDir() throws Exception { + URL url = RouteAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } + + /// ASM-based introspection of the generated Routes class. Captures the + /// answers to the questions we want to assert in tests. + private static RoutesIntrospection readRoutes(File classesDir) throws Exception { + File routesFile = new File(classesDir, ROUTES_PATH); + assertTrue("generated Routes.class missing: " + routesFile, routesFile.exists()); + byte[] bytes = Files.readAllBytes(routesFile.toPath()); + final RoutesIntrospection rx = new RoutesIntrospection(); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + if ("".equals(name)) { + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String mname, + String desc, boolean iface) { + if (opcode == Opcodes.INVOKESTATIC + && "com/codename1/router/Navigation".equals(owner) + && "setDispatcher".equals(mname)) { + rx.bootstrapInstallsViaNavigation = true; + } + } + }; + } + if ("dispatch".equals(name)) { + if (descriptor != null && descriptor.endsWith(")Lcom/codename1/ui/Form;")) { + rx.dispatchReturnsForm = true; + } + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitTypeInsn(int opcode, String type) { + if (opcode == Opcodes.NEW) { + rx.newInstances.add(type); + } + } + + @Override + public void visitMethodInsn(int opcode, String owner, String mname, + String desc, boolean iface) { + if (opcode == Opcodes.INVOKESTATIC) { + rx.staticInvokes.add(owner + "#" + mname); + } + } + }; + } + return null; + } + }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); + return rx; + } + + private static void assertFalse(boolean condition) { + org.junit.Assert.assertFalse(condition); + } + + private static final class RoutesIntrospection { + boolean bootstrapInstallsViaNavigation; + boolean dispatchReturnsForm; + final List newInstances = new ArrayList(); + final List staticInvokes = new ArrayList(); + + boolean instantiates(String internalName) { + return newInstances.contains(internalName); + } + + boolean invokesStatic(String owner, String method) { + return staticInvokes.contains(owner + "#" + method); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AasaBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AasaBuilderTest.java new file mode 100644 index 0000000000..4d5f2e073e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AasaBuilderTest.java @@ -0,0 +1,72 @@ +/* + * 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.maven.routing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AasaBuilderTest { + + @Test + void buildsCanonicalEnvelope() { + String json = new AasaBuilder() + .appId("ABCD1234.com.example.app") + .addPath("/share/*") + .addPath("NOT /admin/*") + .build(); + assertTrue(json.contains("\"applinks\"")); + assertTrue(json.contains("\"ABCD1234.com.example.app\"")); + assertTrue(json.contains("\"/\": \"/share/*\"")); + assertTrue(json.contains("\"/\": \"/admin/*\"")); + assertTrue(json.contains("\"exclude\": true")); + } + + @Test + void routerPatternIsConvertedToAasaWildcards() { + assertEquals("/users/*", AasaBuilder.toAasaPath("/users/:id")); + assertEquals("/files/*", AasaBuilder.toAasaPath("/files/*")); + assertEquals("/share/*", AasaBuilder.toAasaPath("/share/**")); + } + + @Test + void multipleAppEntries() { + String json = new AasaBuilder() + .appId("T.com.example.a").addPath("/a/*") + .appId("T.com.example.b").addPath("/b/*") + .build(); + // Crude but resilient: both team IDs present, two object entries. + assertTrue(json.contains("T.com.example.a")); + assertTrue(json.contains("T.com.example.b")); + int firstAppIDs = json.indexOf("\"appIDs\""); + int secondAppIDs = json.indexOf("\"appIDs\"", firstAppIDs + 1); + assertTrue(secondAppIDs > firstAppIDs, "two appIDs blocks expected"); + } + + @Test + void addPathBeforeAppIdThrows() { + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + @Override public void execute() { new AasaBuilder().addPath("/x"); } + }); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AssetLinksBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AssetLinksBuilderTest.java new file mode 100644 index 0000000000..86c29288e4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/routing/AssetLinksBuilderTest.java @@ -0,0 +1,66 @@ +/* + * 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.maven.routing; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class AssetLinksBuilderTest { + + @Test + void singleAppEntry() { + String json = new AssetLinksBuilder() + .addApp("com.example.app", "AB:CD:EF") + .build(); + assertTrue(json.contains("\"com.example.app\"")); + assertTrue(json.contains("\"AB:CD:EF\"")); + assertTrue(json.contains("delegate_permission/common.handle_all_urls")); + } + + @Test + void additionalFingerprintAttachesToLastApp() { + String json = new AssetLinksBuilder() + .addApp("com.example.app", "AAA") + .addFingerprint("BBB") + .build(); + // both fingerprints should appear in the same array + int aaa = json.indexOf("\"AAA\""); + int bbb = json.indexOf("\"BBB\""); + assertTrue(aaa > 0 && bbb > 0 && bbb > aaa); + } + + @Test + void addAppRequiresFingerprint() { + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + @Override public void execute() { new AssetLinksBuilder().addApp("p", ""); } + }); + } + + @Test + void addFingerprintBeforeAppThrows() { + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + @Override public void execute() { new AssetLinksBuilder().addFingerprint("AAA"); } + }); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/router/PopGuardTest.java b/maven/core-unittests/src/test/java/com/codename1/router/PopGuardTest.java new file mode 100644 index 0000000000..b2794fcb62 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/router/PopGuardTest.java @@ -0,0 +1,73 @@ +/* + * 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.router; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.Form; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class PopGuardTest extends UITestBase { + + @Test + void noGuardAllowsPop() { + Form f = new Form(); + assertTrue(f.checkPopGuard(PopReason.PROGRAMMATIC)); + } + + @Test + void installedGuardCanDeny() { + Form f = new Form(); + f.setPopGuard(new PopGuard() { + public boolean canPop(Form form, PopReason reason) { return false; } + }); + assertFalse(f.checkPopGuard(PopReason.BACK_COMMAND)); + } + + @Test + void guardSeesReason() { + final PopReason[] seen = new PopReason[1]; + Form f = new Form(); + f.setPopGuard(new PopGuard() { + public boolean canPop(Form form, PopReason reason) { + seen[0] = reason; + return true; + } + }); + f.checkPopGuard(PopReason.HARDWARE_BACK); + assertSame(PopReason.HARDWARE_BACK, seen[0]); + } + + @Test + void throwingGuardDefaultsToAllow() { + Form f = new Form(); + f.setPopGuard(new PopGuard() { + public boolean canPop(Form form, PopReason reason) { + throw new RuntimeException("boom"); + } + }); + // Throwing must not propagate; navigation should continue (true). + assertTrue(f.checkPopGuard(PopReason.PROGRAMMATIC)); + } +} diff --git a/scripts/hellocodenameone/common/pom.xml b/scripts/hellocodenameone/common/pom.xml index 142ce7943d..82ef6b6238 100644 --- a/scripts/hellocodenameone/common/pom.xml +++ b/scripts/hellocodenameone/common/pom.xml @@ -352,6 +352,7 @@ compliance-check css + process-annotations