diff --git a/.github/workflows/input-validation.yml b/.github/workflows/input-validation.yml index c929ac50df..f3e0ab9a12 100644 --- a/.github/workflows/input-validation.yml +++ b/.github/workflows/input-validation.yml @@ -127,6 +127,12 @@ jobs: restore-keys: | cn1-binaries-${{ runner.os }}- + # Use the exact key build-port saved against (published as a job + # output). Recomputing the src_hash on this runner has been + # observed to produce a different value than build-port on the + # same SHA -- and even when the algorithm is identical, any new + # source path added to the hash here has to land in three + # places at once or the keys diverge. - name: Restore built CN1 + iOS port artifacts uses: actions/cache/restore@v4 with: @@ -134,7 +140,7 @@ jobs: ~/.m2/repository/com/codenameone Themes Ports/iOSPort/nativeSources - key: cn1-built-${{ runner.os }}-${{ steps.src_hash.outputs.hash }} + key: ${{ needs.build-port.outputs.cn1_built_cache_key }} fail-on-cache-miss: true - name: Install XcodeGen diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index 79974ac5eb..3959f5dbd2 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -71,7 +71,7 @@ jobs: - name: Set up JDK 25 uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'temurin' java-version: '25' - name: Save JDK 25 Path run: echo "JDK_25_HOME=$JAVA_HOME" >> $GITHUB_ENV diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 95f198b8ff..3db9a4cbaa 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -113,7 +113,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: ${{ matrix.java_version }} - distribution: 'zulu' + distribution: 'temurin' - name: Set JDK_HOME if: matrix.id != 'default' run: echo "JDK_HOME=${JAVA_HOME}" >> $GITHUB_ENV diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index 7b0704eef3..3ee8f67acf 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -87,7 +87,7 @@ jobs: - name: Set up Java 8 for ParparVM uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'temurin' java-version: '8' cache: 'maven' @@ -102,7 +102,7 @@ jobs: - name: Set up Java 17 uses: actions/setup-java@v4 with: - distribution: 'zulu' + distribution: 'temurin' java-version: '17' cache: 'maven' diff --git a/.github/workflows/scripts-javase.yml b/.github/workflows/scripts-javase.yml index 06d4a0ab98..e824d2e9f1 100644 --- a/.github/workflows/scripts-javase.yml +++ b/.github/workflows/scripts-javase.yml @@ -40,7 +40,7 @@ jobs: uses: actions/setup-java@v4 with: java-version: '8' - distribution: 'zulu' + distribution: 'temurin' - name: Set TMPDIR run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV" diff --git a/CodenameOne/src/com/codename1/annotations/rest/Body.java b/CodenameOne/src/com/codename1/annotations/rest/Body.java new file mode 100644 index 0000000000..8514a4011f --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/Body.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks an interface method parameter as the request body. The +/// processor serialises the argument via `Mappers.toJson(...)` and +/// attaches it with `Content-Type: application/json`. At most one +/// `@Body`-annotated parameter is allowed per method. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Body { +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/Cookie.java b/CodenameOne/src/com/codename1/annotations/rest/Cookie.java new file mode 100644 index 0000000000..f86e67e6d9 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/Cookie.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds an interface method parameter to a `Cookie` request-header +/// entry. Multiple `@Cookie`-annotated parameters on the same method +/// are joined into a single `Cookie: a=1; b=2` header. `null` +/// arguments are skipped; values are URL-encoded. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Cookie { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/DELETE.java b/CodenameOne/src/com/codename1/annotations/rest/DELETE.java new file mode 100644 index 0000000000..147b92fe82 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/DELETE.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// HTTP `DELETE` request. See [GET] for path semantics. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface DELETE { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/GET.java b/CodenameOne/src/com/codename1/annotations/rest/GET.java new file mode 100644 index 0000000000..a20bc71d6e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/GET.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// HTTP `GET` request. The value is the URL path relative to the +/// `baseUrl` passed to `Api.of(baseUrl)`. Path placeholders such +/// as `/pet/{petId}` are substituted from method parameters annotated +/// with [Path]. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface GET { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/Header.java b/CodenameOne/src/com/codename1/annotations/rest/Header.java new file mode 100644 index 0000000000..ec82859d79 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/Header.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds an interface method parameter to an HTTP request header. +/// `null` argument values skip the header. `Authorization` is the +/// usual carrier for the bearer token argument emitted by +/// `cn1:generate-openapi`. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Header { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/PATCH.java b/CodenameOne/src/com/codename1/annotations/rest/PATCH.java new file mode 100644 index 0000000000..5601b49057 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/PATCH.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// HTTP `PATCH` request. See [GET] for path semantics. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PATCH { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/POST.java b/CodenameOne/src/com/codename1/annotations/rest/POST.java new file mode 100644 index 0000000000..7388f31587 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/POST.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// HTTP `POST` request. See [GET] for path semantics. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface POST { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/PUT.java b/CodenameOne/src/com/codename1/annotations/rest/PUT.java new file mode 100644 index 0000000000..d86e87766a --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/PUT.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// HTTP `PUT` request. See [GET] for path semantics. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PUT { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/Path.java b/CodenameOne/src/com/codename1/annotations/rest/Path.java new file mode 100644 index 0000000000..363ee2d17e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/Path.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds an interface method parameter to a `{name}` placeholder in +/// the URL path declared on the verb annotation. The parameter value +/// is URL-encoded before substitution. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Path { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/Query.java b/CodenameOne/src/com/codename1/annotations/rest/Query.java new file mode 100644 index 0000000000..a5ea6925ec --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/Query.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Binds an interface method parameter to a URL query-string entry. +/// `null` arguments are skipped; collections are appended as repeated +/// `name=value` pairs. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Query { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/rest/RestClient.java b/CodenameOne/src/com/codename1/annotations/rest/RestClient.java new file mode 100644 index 0000000000..ae5ad90126 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/rest/RestClient.java @@ -0,0 +1,31 @@ +/* + * 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. + */ +package com.codename1.annotations.rest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks an interface as a REST client that the build-time +/// annotation processor wires up to a generated network implementation. +/// Companion HTTP-verb annotations ([GET], [POST], [PUT], [DELETE], +/// [PATCH]) on each method carry the path; parameter annotations +/// ([Path], [Query], [Header], [Body]) describe how each argument is +/// attached to the request. +/// +/// The processor emits a `ApiImpl` class in generated-sources and +/// registers it with [com.codename1.io.rest.RestClients] so the +/// interface's `static T of(String baseUrl)` factory can return an +/// instance without the project source referencing the impl directly. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface RestClient { +} diff --git a/CodenameOne/src/com/codename1/io/JSONWriter.java b/CodenameOne/src/com/codename1/io/JSONWriter.java new file mode 100644 index 0000000000..d01de29f3e --- /dev/null +++ b/CodenameOne/src/com/codename1/io/JSONWriter.java @@ -0,0 +1,232 @@ +/* + * 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. + */ +package com.codename1.io; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Convenience JSON writer to complement [JSONParser]. Two access modes: +/// +/// 1. **One-shot**: `JSONWriter.toJson(map)` / +/// `JSONWriter.toJson(map, writer)` -- accepts a `Map`, `List`, `String`, +/// `Number`, `Boolean`, `null`, and arbitrarily nested combinations. +/// +/// 2. **Fluent builder**: `JSONWriter.object().put("name", x).put("values", +/// JSONWriter.array().add("a").add("b")).toJson()` -- for ad-hoc request +/// bodies where a `Map` literal would be noisier than the chain. +/// +/// Encoded output is strict JSON: no trailing commas, all strings +/// double-quoted with the standard backslash escapes for `"`, `\`, `\n`, +/// `\r`, `\t`, and control chars `< 0x20` emitted as `\` + `u00xx`. No +/// pretty-printing layer is included; if you need indented output, run +/// the result through an external formatter at debug time. +/// +/// For typed mapper-based serialization (DTOs annotated with `@Mapped` +/// from the binding framework), use `com.codename1.mapping.Mappers#toJson` +/// instead. `JSONWriter` is for ad-hoc and untyped payloads. +public final class JSONWriter { + + private JSONWriter() { } + + // ---- one-shot ---- + + /// Encodes `value` as JSON and returns the resulting string. Accepts + /// `Map`, `List`, `String`, `Number`, `Boolean`, `null`. Maps with + /// non-String keys are encoded using `String.valueOf(key)`. + public static String toJson(Object value) { + StringBuilder sb = new StringBuilder(); + writeJson(value, sb); + return sb.toString(); + } + + /// Streams `value` as JSON into `writer`. The writer is **not** closed + /// or flushed by this method -- the caller owns the writer. + public static void toJson(Object value, Writer writer) throws IOException { + StringBuilder sb = new StringBuilder(); + writeJson(value, sb); + writer.write(sb.toString()); + } + + /// Streams `value` as JSON into `out` using UTF-8 encoding. The stream + /// is flushed but **not** closed. + public static void toJson(Object value, OutputStream out) throws IOException { + OutputStreamWriter w = new OutputStreamWriter(out, "UTF-8"); + toJson(value, w); + w.flush(); + } + + // ---- fluent builder ---- + + /// Starts a JSON-object builder. Insertion order is preserved. + public static ObjectBuilder object() { + return new ObjectBuilder(); + } + + /// Starts a JSON-array builder. + public static ArrayBuilder array() { + return new ArrayBuilder(); + } + + /// Fluent builder for `{ "k": v, ... }`. Implements `toJson()` to emit + /// the encoded string and exposes the backing `Map` via `toMap()` for + /// callers that need to embed the builder into a larger structure. + public static final class ObjectBuilder { + private final Map map = new LinkedHashMap(); + + ObjectBuilder() { } + + public ObjectBuilder put(String key, Object value) { + map.put(key, unwrap(value)); + return this; + } + + public Map toMap() { + return map; + } + + public String toJson() { + return JSONWriter.toJson(map); + } + + @Override + public String toString() { + return toJson(); + } + } + + /// Fluent builder for `[ ..., ..., ... ]`. + public static final class ArrayBuilder { + private final List list = new ArrayList(); + + ArrayBuilder() { } + + public ArrayBuilder add(Object value) { + list.add(unwrap(value)); + return this; + } + + public List toList() { + return list; + } + + public String toJson() { + return JSONWriter.toJson(list); + } + + @Override + public String toString() { + return toJson(); + } + } + + // ---- internal encoding ---- + + /// Lets builders embed each other transparently: `object().put("xs", + /// array().add(1).add(2))` stores the *list*, not the builder wrapper. + private static Object unwrap(Object value) { + if (value instanceof ObjectBuilder) { + return ((ObjectBuilder) value).map; + } + if (value instanceof ArrayBuilder) { + return ((ArrayBuilder) value).list; + } + return value; + } + + private static void writeJson(Object o, StringBuilder sb) { + if (o == null) { + sb.append("null"); + return; + } + if (o instanceof Boolean || o instanceof Number) { + sb.append(o); + return; + } + if (o instanceof Map) { + Map m = (Map) o; + sb.append('{'); + boolean first = true; + for (Map.Entry e : m.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + writeString(String.valueOf(e.getKey()), sb); + sb.append(':'); + writeJson(e.getValue(), sb); + } + sb.append('}'); + return; + } + if (o instanceof List) { + sb.append('['); + boolean first = true; + for (Object e : (List) o) { + if (!first) { + sb.append(','); + } + first = false; + writeJson(e, sb); + } + sb.append(']'); + return; + } + if (o instanceof ObjectBuilder) { + writeJson(((ObjectBuilder) o).map, sb); + return; + } + if (o instanceof ArrayBuilder) { + writeJson(((ArrayBuilder) o).list, sb); + return; + } + writeString(String.valueOf(o), sb); + } + + private static void writeString(String s, StringBuilder sb) { + sb.append('"'); + int n = s.length(); + for (int i = 0; i < n; i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + case '\b': sb.append("\\b"); break; + case '\f': sb.append("\\f"); break; + default: + if (c < 0x20) { + sb.append("\\u"); + String hex = Integer.toHexString(c); + for (int p = hex.length(); p < 4; p++) { + sb.append('0'); + } + sb.append(hex); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } +} diff --git a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java index 13e7394c5a..8d30878c15 100644 --- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java +++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java @@ -695,6 +695,127 @@ public ConnectionRequest fetchAsJsonMap(final OnComplete> callback return request; } + /// Executes the request asynchronously when the server is expected to return + /// a **top-level JSON array** (`[{...}, {...}]`). Internally this funnels + /// through the same JSON parser as `#fetchAsJsonMap(OnComplete)`, which + /// wraps top-level arrays under the synthetic key `"root"`; this builder + /// unwraps that key for you so the callback receives the array directly: + /// + /// ```java + /// Rest.get("https://api.example.com/items") + /// .header("Authorization", "Bearer " + token) + /// .acceptJson() + /// .fetchAsJsonList(response -> { + /// List items = response.getResponseData(); + /// renderItems(items); + /// }); + /// ``` + /// + /// If the server returns a JSON object instead of an array, the callback + /// receives an empty list. If you don't know up-front whether the + /// response is an array or an object, use `#fetchAsJsonMap(OnComplete)` + /// and branch on `data.get("root") instanceof List`. + /// + /// #### Parameters + /// + /// - `callback`: writes the response (with the unwrapped list) to this + /// callback. Always invoked on the EDT. + /// + /// #### Returns + /// + /// returns the Connection Request object so it can be killed if necessary + public ConnectionRequest fetchAsJsonList(final OnComplete> callback) { + final Connection request = createRequest(true); + request.addResponseListener(new FetchAsJsonListActionListener(request, callback)); + fetched = true; + CN.addToQueue(request); + return request; + } + + /// Executes the request asynchronously, parses the JSON response, and + /// hands the typed DTO to `callback`. Uses the build-time POJO binding + /// framework: `type` must be annotated with `@Mapped` (see + /// `com.codename1.annotations.Mapped` / + /// `com.codename1.mapping.Mappers`) so the build registers a typed + /// mapper for it. + /// + /// ```java + /// // model + /// @Mapped public final class Asset { + /// @JsonProperty public String id; + /// @JsonProperty public String originalFileName; + /// } + /// + /// // call site + /// Rest.get(url + "/assets/" + id) + /// .header("Authorization", "Bearer " + token) + /// .acceptJson() + /// .fetchAsMapped(Asset.class, response -> { + /// Asset a = response.getResponseData(); // already typed -- no Map casts + /// render(a); + /// }); + /// ``` + /// + /// Compared to `#fetchAsJsonMap(OnComplete)`: no `(Map) cast`, no + /// `m.get("id")` boilerplate, no key-typo surprises at runtime. The + /// per-class mapper is generated by the Maven plugin's + /// `process-annotations` mojo from the `@Mapped` annotation and lives + /// in `.generated.Mapper`. + /// + /// If the type has no registered mapper at runtime, the listener + /// completes with `null` data and a non-200 response code is *not* + /// synthesised -- inspect `response.getResponseCode()` to differentiate + /// "server returned an error" from "no mapper registered". + /// + /// #### Parameters + /// + /// - `type`: the `@Mapped` class to deserialise into + /// - `callback`: invoked on the EDT with the typed result + /// + /// #### Returns + /// + /// the Connection Request object so it can be killed if necessary + public ConnectionRequest fetchAsMapped(final Class type, final OnComplete> callback) { + final Connection request = createRequest(true); + request.addResponseListener(new FetchAsMappedActionListener(request, type, callback)); + fetched = true; + CN.addToQueue(request); + return request; + } + + /// List-typed variant of `#fetchAsMapped(Class, OnComplete)`. Use when + /// the server returns a top-level JSON array of DTOs: + /// + /// ```java + /// Rest.get(url + "/albums") + /// .header("Authorization", "Bearer " + token) + /// .acceptJson() + /// .fetchAsMappedList(Album.class, response -> { + /// List albums = response.getResponseData(); + /// renderAlbums(albums); + /// }); + /// ``` + /// + /// Internally goes through the same `{"root": [...]}` envelope as + /// `#fetchAsJsonList(OnComplete)`, then maps each element through the + /// registered mapper for `type`. + /// + /// #### Parameters + /// + /// - `type`: the per-element `@Mapped` class + /// - `callback`: invoked on the EDT with `List` data + /// + /// #### Returns + /// + /// the Connection Request object so it can be killed if necessary + public ConnectionRequest fetchAsMappedList(final Class type, final OnComplete>> callback) { + final Connection request = createRequest(true); + request.addResponseListener(new FetchAsMappedListActionListener(request, type, callback)); + fetched = true; + CN.addToQueue(request); + return request; + } + /// Executes the request asynchronously and writes the response to the provided /// Callback. This fetches JSON data and parses it into a properties business object /// @@ -1146,6 +1267,116 @@ public void actionPerformed(NetworkEvent evt) { } } + private static class FetchAsJsonListActionListener implements ActionListener { + private final Connection request; + private final OnComplete> callback; + + public FetchAsJsonListActionListener(Connection request, OnComplete> callback) { + this.request = request; + this.callback = callback; + } + + @Override + public void actionPerformed(NetworkEvent evt) { + if (request.errorCode) { + return; + } + // The JSONParser wraps top-level arrays under "root". + Map parsed = (Map) evt.getMetaData(); + List list; + if (parsed != null && parsed.get("root") instanceof List) { + list = (List) parsed.get("root"); + } else { + list = java.util.Collections.emptyList(); + } + Response res = new Response(evt.getResponseCode(), list, evt.getMessage()); + callback.completed(res); + } + } + + /// Routes a JSON-object response through the build-time POJO mapping + /// framework. Returns a `null` typed result when no mapper is registered + /// for `type` (typical cause: forgot `@Mapped` on the class, or the + /// process-annotations Mojo didn't run). + private static class FetchAsMappedActionListener implements ActionListener { + private final Connection request; + private final Class type; + private final OnComplete> callback; + + public FetchAsMappedActionListener(Connection request, Class type, OnComplete> callback) { + this.request = request; + this.type = type; + this.callback = callback; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void actionPerformed(NetworkEvent evt) { + if (request.errorCode) { + return; + } + Map parsed = (Map) evt.getMetaData(); + T result = null; + if (parsed != null) { + com.codename1.mapping.Mapper m = + com.codename1.mapping.Mappers.get(type); + if (m != null) { + result = m.fromMap(parsed); + } + } + Response res = new Response(evt.getResponseCode(), result, evt.getMessage()); + callback.completed(res); + } + } + + /// List-typed mapped variant. Pulls the array out of the top-level + /// JSON envelope (same as `FetchAsJsonListActionListener`), then maps + /// each element through the registered mapper for `type`. + private static class FetchAsMappedListActionListener implements ActionListener { + private final Connection request; + private final Class type; + private final OnComplete>> callback; + + public FetchAsMappedListActionListener(Connection request, Class type, OnComplete>> callback) { + this.request = request; + this.type = type; + this.callback = callback; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public void actionPerformed(NetworkEvent evt) { + if (request.errorCode) { + return; + } + Map parsed = (Map) evt.getMetaData(); + List raw = null; + if (parsed != null && parsed.get("root") instanceof List) { + raw = (List) parsed.get("root"); + } + List out; + if (raw == null) { + out = java.util.Collections.emptyList(); + } else { + com.codename1.mapping.Mapper m = + com.codename1.mapping.Mappers.get(type); + if (m == null) { + out = java.util.Collections.emptyList(); + } else { + out = new ArrayList(raw.size()); + for (Object e : raw) { + if (e instanceof Map) { + Map mm = (Map) e; + out.add(m.fromMap(mm)); + } + } + } + } + Response> res = new Response>(evt.getResponseCode(), out, evt.getMessage()); + callback.completed(res); + } + } + private static class GetAsBytesAsyncImplActionListener implements ActionListener { private final Connection request; private final Object callback; diff --git a/CodenameOne/src/com/codename1/io/rest/RestClients.java b/CodenameOne/src/com/codename1/io/rest/RestClients.java new file mode 100644 index 0000000000..e98bee874c --- /dev/null +++ b/CodenameOne/src/com/codename1/io/rest/RestClients.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. + */ +package com.codename1.io.rest; + +import java.util.HashMap; +import java.util.Map; + +/// Runtime registry that wires `@RestClient`-annotated interfaces to +/// the build-time-generated implementations. The generated +/// `cn1app.RestClientBootstrap` calls [#register(Class, Factory)] +/// for every API interface in the project; user code reaches them +/// via the `static of(String baseUrl)` factory that +/// `cn1:generate-openapi` puts on each interface, and that factory +/// in turn calls [#create(Class, String)] here. +public final class RestClients { + + private static final Map, Factory> REGISTRY = new HashMap, Factory>(); + + private RestClients() { + } + + /// Registers a factory for an `@RestClient`-annotated interface. + /// Called from the generated `cn1app.RestClientBootstrap` so the + /// REST plumbing is set up before the first `.of(baseUrl)` + /// call. + public static void register(Class apiType, Factory factory) { + if (apiType == null || factory == null) { + return; + } + REGISTRY.put(apiType, factory); + } + + /// Returns a freshly-built client for the requested API. Called by + /// the `static of(String baseUrl)` factory emitted on every + /// generated `@RestClient` interface. + @SuppressWarnings("unchecked") + public static T create(Class apiType, String baseUrl) { + Factory factory = (Factory) REGISTRY.get(apiType); + if (factory == null) { + throw new IllegalStateException( + "No RestClient impl registered for " + apiType.getName() + + " -- did cn1:process-annotations run?"); + } + return factory.create(baseUrl); + } + + /// Factory the generated bootstrap registers per API interface. + /// Modeled as a single-method interface (no + /// `java.util.function.Function`) so CLDC-targeted builds remain + /// happy. + public interface Factory { + T create(String baseUrl); + } +} diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index d25adfa0e1..ea2d3d407b 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -5265,6 +5265,17 @@ public void addPullToRefresh(Runnable task) { this.refreshTask = task; } + /// Alias for `#addPullToRefresh(Runnable)` -- both names point at the + /// same single-task slot, and a second call replaces the + /// previously-registered runnable. + /// + /// #### Parameters + /// + /// - `task`: the refresh task to execute, or `null` to clear. + public void setPullToRefresh(Runnable task) { + this.refreshTask = task; + } + /// Checks if the component responds to pointer events. A component is considered /// to respond to pointer events if it is visible and enabled, and is either scrollable, /// focusable, or has the `#isGrabsPointerEvents()` flag true. diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index 786fefc565..db88706f69 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -138,6 +138,22 @@ public class Tabs extends Container { private boolean blockSwipe; private boolean riskySwipe; + // ---- Animated tab indicator (Material 3 "NavigationBar" style) ---- + // Off by default. Enable with #setAnimatedIndicator(true) or the + // `tabsAnimatedIndicatorBool` theme constant. When on, a coloured + // underline drawn under the currently-selected tab tweens its + // x/width between the previous and new tabs on selection change. + private boolean animatedIndicator; + private int animatedIndicatorDurationMs = 200; + private int animatedIndicatorThicknessMm = 1; // 1mm-tall underline + private Motion indicatorAnimMotion; + // Tab bounds at the start of the indicator animation. + private int indicatorFromX; + private int indicatorFromW; + // Tab bounds at the end of the indicator animation. + private int indicatorToX; + private int indicatorToW; + /// Creates an empty `TabbedPane` with a default /// tab placement of `Component.TOP`. public Tabs() { @@ -157,7 +173,18 @@ public Tabs(int tabP) { focusListener = new TabFocusListener(); contentPane.setUIID("TabbedPane"); super.addComponent(BorderLayout.CENTER, contentPane); - tabsContainer = new Container(); + // Custom Container subclass that lets us paint the animated indicator + // on top of children (over the tab buttons' selected-state background) + // when `animatedIndicator` is on. When the feature is off, the + // override is a no-op extra call and visually indistinguishable from + // a plain Container. + tabsContainer = new Container() { + @Override + public void paint(Graphics g) { + super.paint(g); + paintAnimatedIndicator(g); + } + }; // tabsSafeAreaBool=true (default): legacy / flush-bar themes keep the // safe-area inset as PADDING on the pill itself - the bar's // background reaches the screen edge with tabs sitting above the @@ -197,6 +224,9 @@ public Tabs(int tabP) { drag = new SwipeListener(SwipeListener.DRAG); release = new SwipeListener(SwipeListener.RELEASE); setUIIDFinal("Tabs"); + // Opt-in animated indicator (Material 3 NavigationBar style). + animatedIndicator = getUIManager().isThemeConstant("tabsAnimatedIndicatorBool", false); + animatedIndicatorDurationMs = getUIManager().getThemeConstant("tabsAnimatedIndicatorDurationInt", 200); BorderLayout bd = (BorderLayout) super.getLayout(); if (bd != null) { if (UIManager.getInstance().isThemeConstant("tabsOnTopBool", false)) { @@ -320,6 +350,18 @@ protected void initComponent() { @Override public boolean animate() { boolean b = super.animate(); + // Indicator-animation tick: redraw the tab bar each frame while + // the motion is in flight. We let the existing super.animate / + // slide motion control deregistration; the indicator motion is + // cheap enough to run alongside without coordination. + if (indicatorAnimMotion != null) { + if (indicatorAnimMotion.isFinished()) { + indicatorAnimMotion = null; + } else { + tabsContainer.repaint(); + b = true; + } + } if (slideToDestMotion != null) { if (swipeOnXAxis) { int motionX = slideToDestMotion.getValue(); @@ -1258,6 +1300,10 @@ public void setSelectedIndex(int index, boolean slideToSelected) { if (index == activeComponent) { return; } + // Snapshot the current tab bounds *before* we mutate state, so the + // animated indicator can tween from where it visibly is to the new + // selection's bounds. + startIndicatorAnimation(activeComponent, index); Form form = getComponentForm(); if (slideToSelected && form != null) { @@ -1300,6 +1346,109 @@ protected void selectTab(Component tab) { b.requestFocus(); } + /// Enables the Material 3 sliding-underline indicator (off by default). + /// When on, selection changes tween the indicator from the old tab's + /// bounds to the new tab's bounds over `tabsAnimatedIndicatorDurationInt` + /// milliseconds (default 200, ease-in-out cubic). + /// + /// Color is taken from the `TabIndicator` UIID's foreground color when + /// it exists, otherwise from the currently-selected tab's foreground color. + /// Thickness is 1mm; override with the `tabsAnimatedIndicatorThicknessMm` + /// theme constant (in millimeters). + public void setAnimatedIndicator(boolean enable) { + this.animatedIndicator = enable; + // First frame: snap the indicator to the currently-selected tab so + // it appears immediately on enable rather than on the next change. + if (enable && tabsContainer.getComponentCount() > 0) { + Component active = tabsContainer.getComponentAt(activeComponent); + indicatorFromX = active.getX(); + indicatorFromW = active.getWidth(); + indicatorToX = indicatorFromX; + indicatorToW = indicatorFromW; + } + tabsContainer.repaint(); + } + + /// Returns whether the animated tab indicator is enabled. See + /// `#setAnimatedIndicator(boolean)`. + public boolean isAnimatedIndicator() { + return animatedIndicator; + } + + private void startIndicatorAnimation(int fromIndex, int toIndex) { + if (!animatedIndicator || tabsContainer == null) { + return; + } + if (fromIndex < 0 || fromIndex >= tabsContainer.getComponentCount() + || toIndex < 0 || toIndex >= tabsContainer.getComponentCount()) { + return; + } + Component fromTab = tabsContainer.getComponentAt(fromIndex); + Component toTab = tabsContainer.getComponentAt(toIndex); + // If a motion is already in flight, start from the *current* + // interpolated position, not from the previous tab -- otherwise + // rapid double-clicks jump back to a stale baseline. + if (indicatorAnimMotion != null && !indicatorAnimMotion.isFinished()) { + int v = indicatorAnimMotion.getValue(); + indicatorFromX = indicatorFromX + ((indicatorToX - indicatorFromX) * v / 100); + indicatorFromW = indicatorFromW + ((indicatorToW - indicatorFromW) * v / 100); + } else { + indicatorFromX = fromTab.getX(); + indicatorFromW = fromTab.getWidth(); + } + indicatorToX = toTab.getX(); + indicatorToW = toTab.getWidth(); + indicatorAnimMotion = Motion.createEaseInOutMotion(0, 100, animatedIndicatorDurationMs); + indicatorAnimMotion.start(); + Form f = getComponentForm(); + if (f != null) { + f.registerAnimatedInternal(this); + } + } + + /// Draws the animated indicator inside `tabsContainer`'s paint flow. Called + /// from the inner `Container` subclass installed as `tabsContainer`. + void paintAnimatedIndicator(Graphics g) { + if (!animatedIndicator || tabsContainer.getComponentCount() == 0) { + return; + } + int x; + int w; + if (indicatorAnimMotion != null) { + int v = indicatorAnimMotion.getValue(); // 0..100 + x = indicatorFromX + ((indicatorToX - indicatorFromX) * v / 100); + w = indicatorFromW + ((indicatorToW - indicatorFromW) * v / 100); + } else { + // At rest: pin to the currently-selected tab. + Component active = tabsContainer.getComponentAt(activeComponent); + x = active.getX(); + w = active.getWidth(); + } + int thicknessMm = getUIManager().getThemeConstant("tabsAnimatedIndicatorThicknessMm", animatedIndicatorThicknessMm); + int thickness = Display.getInstance().convertToPixels(thicknessMm); + // Use TabIndicator UIID color when its fg is set; otherwise pull + // from the selected tab's foreground. `getComponentStyle(...)` + // never returns null -- it synthesises an empty Style if no + // matching UIID exists -- so a `null` check on the result would + // be redundant. + int color; + Style indicatorStyle = getUIManager().getComponentStyle("TabIndicator"); + if (indicatorStyle.getFgColor() != 0) { + color = indicatorStyle.getFgColor(); + } else { + Component active = tabsContainer.getComponentAt(activeComponent); + color = active.getSelectedStyle().getFgColor(); + } + int oldAlpha = g.getAlpha(); + int oldColor = g.getColor(); + g.setColor(color); + g.setAlpha(255); + int y = tabsContainer.getInnerY() + tabsContainer.getInnerHeight() - thickness; + g.fillRect(tabsContainer.getInnerX() + x, y, w, thickness); + g.setColor(oldColor); + g.setAlpha(oldAlpha); + } + /// Hide the tabs bar public void hideTabs() { removeComponent(tabsContainerHost != null ? tabsContainerHost : tabsContainer); diff --git a/CodenameOne/src/com/codename1/ui/URLImage.java b/CodenameOne/src/com/codename1/ui/URLImage.java index 595c9729a0..2bb4cca376 100644 --- a/CodenameOne/src/com/codename1/ui/URLImage.java +++ b/CodenameOne/src/com/codename1/ui/URLImage.java @@ -165,23 +165,92 @@ public boolean isAsyncAdapter() { private static final EasyThread imageLoader = EasyThread.start("ImageLoader"); /// The exception handler is used for callbacks in case of an error private static ErrorCallback exceptionHandler; + /// Global default applied to every URLImage download request that doesn't + /// already carry a per-instance decorator. See `#setDefaultRequestDecorator`. + private static RequestDecorator defaultRequestDecorator; private final EncodedImage placeholder; private final String url; private final ImageAdapter adapter; private final String storageFile; private final String fileSystemFile; + private final RequestDecorator requestDecorator; private boolean fetching; private byte[] imageData; private boolean repaintImage; private boolean locked; private URLImage(EncodedImage placeholder, String url, ImageAdapter adapter, String storageFile, String fileSystemFile) { + this(placeholder, url, adapter, storageFile, fileSystemFile, null); + } + + private URLImage(EncodedImage placeholder, String url, ImageAdapter adapter, + String storageFile, String fileSystemFile, + RequestDecorator requestDecorator) { super(placeholder.getWidth(), placeholder.getHeight()); this.placeholder = placeholder; this.url = url; this.adapter = adapter; this.storageFile = storageFile; this.fileSystemFile = fileSystemFile; + this.requestDecorator = requestDecorator; + } + + /// Decorator hook applied to the `ConnectionRequest` that loads a + /// network-backed `URLImage`. Useful when the image endpoint sits + /// behind an `Authorization: Bearer ...` header, when you need to + /// override the user-agent, set a cookie, or anything else + /// `ConnectionRequest` exposes. + /// + /// Two ways to install one: + /// + /// - **Global default**: + /// `URLImage.setDefaultRequestDecorator(req -> + /// req.addRequestHeader("Authorization", "Bearer " + token));` -- + /// applies to every `URLImage` from then on. This covers the common + /// "all our images are private" case in one app-boot line. + /// + /// - **Per-image**: use the + /// `createToStorage(EncodedImage, String, String, ImageAdapter, + /// RequestDecorator)` overload. Per-instance decorators run **after** + /// the global default, so the per-call decorator can override or + /// augment headers set by the default. + public interface RequestDecorator { + void decorate(com.codename1.io.ConnectionRequest req); + } + + /// Installs a global `RequestDecorator`. Pass `null` to clear the + /// existing default. The decorator runs on every URLImage's + /// `ConnectionRequest` immediately before it's queued. + public static void setDefaultRequestDecorator(RequestDecorator decorator) { + defaultRequestDecorator = decorator; + } + + /// Returns the global default decorator installed via + /// `#setDefaultRequestDecorator(RequestDecorator)`, or `null`. + public static RequestDecorator getDefaultRequestDecorator() { + return defaultRequestDecorator; + } + + /// Convenience for the most common case: every URLImage fetch should + /// carry the same `Authorization: Bearer ` header. Equivalent + /// to `setDefaultRequestDecorator(req -> req.addRequestHeader( + /// "Authorization", "Bearer " + token))`. + /// + /// Pass `null` to clear. Subsequent calls replace the previously-set + /// header value; the same `Authorization` header is not appended + /// twice. + public static void setDefaultBearerToken(String token) { + if (token == null) { + defaultRequestDecorator = null; + return; + } + final String headerValue = "Bearer " + token; + defaultRequestDecorator = new RequestDecorator() { + @Override + public void decorate(com.codename1.io.ConnectionRequest req) { + req.addRequestHeader("Authorization", headerValue); + } + }; } /// The exception handler is used for callbacks in case of an error @@ -283,12 +352,27 @@ public static URLImage createToStorage(EncodedImage placeholder, String storageF /// /// a URLImage that will initialy just delegate to the placeholder public static URLImage createToStorage(EncodedImage placeholder, String storageFile, String url, ImageAdapter adapter) { + return createToStorage(placeholder, storageFile, url, adapter, null); + } + + /// Same as `#createToStorage(EncodedImage, String, String, ImageAdapter)`, + /// plus a per-call `RequestDecorator` applied to the + /// `ConnectionRequest` that fetches the image bytes. Use when the URL + /// requires authentication, custom headers, or other + /// `ConnectionRequest` configuration not covered by the default. + /// + /// If a global default decorator is also installed via + /// `#setDefaultRequestDecorator(RequestDecorator)`, the global default + /// runs first, then the per-call decorator -- so the per-call decorator + /// can override headers set by the default. + public static URLImage createToStorage(EncodedImage placeholder, String storageFile, String url, + ImageAdapter adapter, RequestDecorator decorator) { // intern is used to trigger an NPE in case of a null URL or storage file URLImage out = pendingToStorage.get(storageFile); if (out != null) { return out; } - out = new URLImage(placeholder, url.intern(), adapter, storageFile.intern(), null); + out = new URLImage(placeholder, url.intern(), adapter, storageFile.intern(), null, decorator); pendingToStorage.put(storageFile, out); return out; } @@ -502,56 +586,58 @@ public void fetch() { } if (adapter != null) { if (url.startsWith("http://") || url.startsWith("https://")) { - Util.downloadImageToStorage(url, storageFile + IMAGE_SUFFIX, - new SuccessCallback() { + SuccessCallback downloadCb = new SuccessCallback() { + @Override + public void onSucess(final Image value) { + imageLoader.run(new Runnable() { @Override - public void onSucess(final Image value) { - imageLoader.run(new Runnable() { + public void run() { + runAndWait(new Runnable() { @Override public void run() { - runAndWait(new Runnable() { - @Override - public void run() { - DownloadCompleted onComplete = new DownloadCompleted(); - onComplete.setSourceImage(value); - onComplete.actionPerformed(new ActionEvent(value)); - } - }); + DownloadCompleted onComplete = new DownloadCompleted(); + onComplete.setSourceImage(value); + onComplete.actionPerformed(new ActionEvent(value)); } }); - - } - }); + } + }; + if (hasRequestDecorator()) { + downloadDecorated(url, storageFile + IMAGE_SUFFIX, downloadCb); + } else { + Util.downloadImageToStorage(url, storageFile + IMAGE_SUFFIX, downloadCb); + } } else { // from file loadImageFromLocalUrl(storageFile + IMAGE_SUFFIX, false); } } else { if (url.startsWith("http://") || url.startsWith("https://")) { - // Load image from http - Util.downloadImageToStorage(url, storageFile, - new SuccessCallback() { + SuccessCallback downloadCb = new SuccessCallback() { + @Override + public void onSucess(final Image value) { + imageLoader.run(new Runnable() { @Override - public void onSucess(final Image value) { - imageLoader.run(new Runnable() { + public void run() { + runAndWait(new Runnable() { @Override public void run() { - runAndWait(new Runnable() { - @Override - public void run() { - DownloadCompleted onComplete = new DownloadCompleted(); - onComplete.setSourceImage(value); - onComplete.actionPerformed(new ActionEvent(value)); - } - }); + DownloadCompleted onComplete = new DownloadCompleted(); + onComplete.setSourceImage(value); + onComplete.actionPerformed(new ActionEvent(value)); } }); - - } }); + } + }; + if (hasRequestDecorator()) { + downloadDecorated(url, storageFile, downloadCb); + } else { + Util.downloadImageToStorage(url, storageFile, downloadCb); + } } else { //load image from file system loadImageFromLocalUrl(storageFile, false); @@ -773,6 +859,53 @@ public boolean isAsyncAdapter() { } } + /// Whether this URLImage has an applicable RequestDecorator (per-instance + /// or a global default). + private boolean hasRequestDecorator() { + return requestDecorator != null || defaultRequestDecorator != null; + } + + /// Downloads `url` to `Storage` under `storageKey`, applying the global + /// default `RequestDecorator` then the per-instance one to the + /// `ConnectionRequest` before queueing. Mirrors the contract of + /// `Util.downloadImageToStorage(url, storageKey, onSuccess)`: invokes + /// `onSuccess` on the EDT with the decoded `Image` when the file is + /// fully written. + /// + /// We can't reuse `Util.downloadImageToStorage(...)` directly here + /// because it offers no extension point for decorating the underlying + /// `ConnectionRequest`; this method assembles an equivalent request + /// inline so the decorators can run before the queue dispatch. + private void downloadDecorated(final String url, final String storageKey, + final SuccessCallback onSuccess) { + com.codename1.io.ConnectionRequest cr = new com.codename1.io.ConnectionRequest(); + cr.setPost(false); + cr.setFailSilently(true); + cr.setReadResponseForErrors(false); + cr.setDuplicateSupported(true); + cr.setUrl(url); + // Run decorators first so anything they set is in place by the time + // the network manager pulls the request off the queue. + if (defaultRequestDecorator != null) { + defaultRequestDecorator.decorate(cr); + } + if (requestDecorator != null) { + requestDecorator.decorate(cr); + } + cr.downloadImageToStorage(storageKey, onSuccess, new FailureCallback() { + @Override + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + if (exceptionHandler != null) { + exceptionHandler.onError(URLImage.this, new IOException( + "Image download failed (" + errorCode + "): " + errorMessage)); + } else { + Log.e(new RuntimeException( + "URLImage download failed (" + errorCode + "): " + errorMessage)); + } + } + }); + } + /// CachedImage used by `java.lang.String, com.codename1.ui.Image, int)` private static class CachedImage extends Image { int resizeRule; diff --git a/CodenameOne/src/com/codename1/ui/animations/MorphTransition.java b/CodenameOne/src/com/codename1/ui/animations/MorphTransition.java index 1f3e2cea5b..0bd45ba25d 100644 --- a/CodenameOne/src/com/codename1/ui/animations/MorphTransition.java +++ b/CodenameOne/src/com/codename1/ui/animations/MorphTransition.java @@ -27,6 +27,7 @@ import com.codename1.ui.Container; import com.codename1.ui.Form; import com.codename1.ui.Graphics; +import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.geom.Dimension; @@ -44,10 +45,52 @@ public final class MorphTransition extends Transition { private CC[] fromToComponents; private Motion animationMotion; private boolean finished; + /// Opt-in snapshot mode -- when on, source / destination components are + /// captured as clipped `Image`s at `initTransition()` time and the tween + /// draws those images rather than re-painting the live components every + /// frame. See `#snapshotMode(boolean)`. + private boolean snapshotMode; private MorphTransition() { } + /// Enables the image-snapshot path. Each `(source, dest)` pair is rendered + /// once into an `Image` at `initTransition()` (clipped to the component's + /// own bounds; off-viewport children do not contribute pixels), then the + /// tween draws those images at the interpolated `(x, y, w, h)`. + /// + /// Use this when: + /// + /// - The source lives inside a scrolling container whose + /// `scrollX`/`scrollY` would otherwise leak off-viewport child pixels + /// into the morph (the cross-form morph clipping artifact). + /// - The source has children with dynamic content (a `BrowserComponent`, + /// a video frame, a custom-painted background) that should be frozen + /// visually for the duration of the animation. + /// - The source's parent applies a clip that the layered pane wouldn't + /// replicate. + /// + /// Default is **off** to preserve back-compat with the legacy live-paint + /// path. Always pair with a screenshot regression test (see + /// `scripts/hellocodenameone/.../MorphTransitionTest`). + /// + /// #### Parameters + /// + /// - `enabled`: `true` to snapshot, `false` for the legacy live-paint mode + /// + /// #### Returns + /// + /// this transition (for chaining with `#morph(String)` etc.) + public MorphTransition snapshotMode(boolean enabled) { + this.snapshotMode = enabled; + return this; + } + + /// Returns the current snapshot-mode setting. See `#snapshotMode(boolean)`. + public boolean isSnapshotMode() { + return snapshotMode; + } + /// Creates a transition with the given duration, this transition should be modified with the /// builder methods such as morph /// @@ -86,6 +129,7 @@ private static Component findByName(Container root, String componentName) { @Override public Transition copy(boolean reverse) { MorphTransition m = create(duration); + m.snapshotMode = snapshotMode; if (reverse) { for (Map.Entry entry : fromTo.entrySet()) { m.fromTo.put(entry.getValue(), entry.getKey()); @@ -151,6 +195,16 @@ public void initTransition() { continue; } CC cc = new CC(sourceCmp, destCmp, duration); + // Snapshot capture happens BEFORE the layered-pane swap, so + // the source still sits inside its original (possibly + // scrolling, possibly clipped) parent and renders the pixels + // the user actually sees at the moment they tap. Capturing + // after the swap would render the layered-pane copy, which + // has no clipping context. + if (snapshotMode) { + cc.sourceImage = captureSnapshot(sourceCmp); + cc.destImage = captureSnapshot(destCmp); + } fromToComponents[index] = cc; index++; cc.placeholderDest = new Label(); @@ -241,18 +295,100 @@ public void paint(Graphics g) { if (animationMotion != null) { alpha = animationMotion.getValue(); } - if (alpha < 255) { - g.setAlpha(255 - alpha); - getSource().paintComponent(g); - - g.setAlpha(alpha); - byte bgT = getDestination().getUnselectedStyle().getBgTransparency(); - getDestination().getUnselectedStyle().setBgTransparency(0); - getDestination().paintComponent(g, false); - getDestination().getUnselectedStyle().setBgTransparency(bgT); + // In snapshot mode we hide the live morphed components on the + // layered pane (they'd otherwise paint themselves on top of the + // captured images), paint the source / dest forms normally, then + // overlay the alpha-blended snapshots at the tweened bounds. + boolean hidSnapshots = false; + if (snapshotMode && fromToComponents != null) { + for (CC c : fromToComponents) { + if (c != null) { + c.source.setVisible(false); + c.dest.setVisible(false); + } + } + hidSnapshots = true; + } + try { + if (alpha < 255) { + g.setAlpha(255 - alpha); + getSource().paintComponent(g); + + g.setAlpha(alpha); + byte bgT = getDestination().getUnselectedStyle().getBgTransparency(); + getDestination().getUnselectedStyle().setBgTransparency(0); + getDestination().paintComponent(g, false); + getDestination().getUnselectedStyle().setBgTransparency(bgT); + g.setAlpha(oldAlpha); + } else { + getDestination().paintComponent(g); + } + if (snapshotMode && fromToComponents != null) { + paintSnapshots(g, alpha); + } + } finally { + if (hidSnapshots) { + for (CC c : fromToComponents) { + if (c != null) { + c.source.setVisible(true); + c.dest.setVisible(true); + } + } + } + g.setAlpha(oldAlpha); + } + } + + /// Snapshot-mode draw of each morphed pair: alpha-blend the source + /// snapshot (decreasing) on top of the destination snapshot + /// (increasing) at the current tweened bounds. The snapshots are + /// scaled to fit those bounds; on hi-DPI this is a nearest-neighbour + /// stretch via `drawImage(scaled)`. Both images already represent the + /// component clipped to its own bounds at the moment of capture, so + /// nothing off-viewport leaks into the morph. + /// + /// Callers must have already invoked `#initTransition()` -- the guard + /// at the top of the method protects against late-call paths + /// (`finished` flush, animation cancel) where the field has been + /// nulled out. + private void paintSnapshots(Graphics g, int alpha) { + CC[] pairs = fromToComponents; + if (pairs == null) { + return; + } + int oldAlpha = g.getAlpha(); + try { + for (CC c : pairs) { + if (c == null || c.sourceImage == null || c.destImage == null) { + continue; + } + int x = c.xMotion.getValue(); + int y = c.yMotion.getValue(); + int w = c.wMotion.getValue(); + int h = c.hMotion.getValue(); + if (w <= 0 || h <= 0) { + continue; + } + // Source fades out + g.setAlpha(255 - alpha); + drawImageScaled(g, c.sourceImage, x, y, w, h); + // Dest fades in + g.setAlpha(alpha); + drawImageScaled(g, c.destImage, x, y, w, h); + } + } finally { g.setAlpha(oldAlpha); + } + } + + /// Draws `img` into the `(x, y, w, h)` rectangle. Skips a scaled copy + /// when the image already happens to be at the target size (cheap + /// fast-path for the first and last frames of the animation). + private static void drawImageScaled(Graphics g, Image img, int x, int y, int w, int h) { + if (img.getWidth() == w && img.getHeight() == h) { + g.drawImage(img, x, y); } else { - getDestination().paintComponent(g); + g.drawImage(img, x, y, w, h); } } @@ -266,6 +402,13 @@ static class CC { Motion wMotion; Motion hMotion; + /// Snapshot-mode capture of `source` at its original bounds, clipped + /// to its own size. Populated in `MorphTransition#captureSnapshot` + /// when `snapshotMode == true`; null on the legacy path. + Image sourceImage; + /// Snapshot-mode capture of `dest` at its destination-form bounds. + Image destImage; + public CC(Component source, Component dest, int duration) { this.source = source; this.dest = dest; @@ -291,4 +434,29 @@ private int positionRelativeToScreen(Component cmp, boolean yAxis) { return retVal; } } + + /// Renders `cmp` into a fresh `Image` sized to its current bounds. Used + /// by snapshot mode in `initTransition()` to freeze each endpoint + /// visually before the tween starts; the resulting image is what the + /// `paint()` cycle draws at the interpolated bounds. + /// + /// The component is painted with `paintComponent` (not `paint`) so its + /// background + border + children are all included. The graphics is + /// translated so the component's `(getX(), getY())` becomes `(0, 0)` in + /// the snapshot. The image's own bounds clip everything that paints + /// outside `(0, 0, width, height)` -- which is exactly the + /// "off-viewport children don't leak" property the legacy live-paint + /// path lacked. + private static Image captureSnapshot(Component cmp) { + int w = Math.max(1, cmp.getWidth()); + int h = Math.max(1, cmp.getHeight()); + Image img = Image.createImage(w, h, 0); + Graphics g = img.getGraphics(); + // paintComponent renders the component at its current screen position + // by default; offset so the top-left of `cmp` lands at (0, 0) of the + // image buffer. The image's bounds clip outside-of-buffer paints. + g.translate(-cmp.getX(), -cmp.getY()); + cmp.paintComponent(g); + return img; + } } diff --git a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java index ea50206b3e..bb766668d1 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java +++ b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java @@ -48,6 +48,7 @@ import com.codename1.ui.TextSelection.Span; import com.codename1.ui.TextSelection.Spans; import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.events.FocusListener; import com.codename1.ui.geom.Dimension; import com.codename1.ui.geom.GeneralPath; @@ -2226,6 +2227,18 @@ private FontImage getDefaultRefreshIcon() { /// {@inheritDoc} @Override public void drawPullToRefresh(Graphics g, final Component cmp, boolean taskExecuted) { + // Modern path: a Material-style circular arc spinner painted + // directly by Graphics, with no Label / rotating-icon + // machinery. Opt-in via the `pullToRefreshModernBool` theme + // constant; the iOS Modern and Android Material themes turn + // this on by default so they get the spec-fidelity look. The + // legacy path below is the framework default and remains + // pixel-identical for apps that don't enable the constant. + if (getUIManager().isThemeConstant("pullToRefreshModernBool", false)) { + drawModernPullToRefresh(g, cmp, taskExecuted); + return; + } + final Form parentForm = cmp.getComponentForm(); final int scrollY = cmp.getScrollY(); Component cmpToDraw; @@ -2309,9 +2322,181 @@ public void paint(Graphics g) { pull.paintComponent(g); } + /// Material 3 / iOS modern pull-to-refresh: a circular arc spinner + /// painted directly with `Graphics`. No Label / rotating-image + /// machinery, no `pull` container layout pass per frame -- just an + /// arc whose sweep grows from 0° to ~330° as the user pulls + /// through the threshold, then spins continuously (advancing the + /// start angle each frame) once the task fires. + /// + /// Geometry: + /// + /// - Indicator diameter: `pullToRefreshIndicatorDiameterMm` (default 8mm). + /// - Stroke thickness: `pullToRefreshIndicatorStrokeMm` (default 0.6mm). + /// - Colour: `TabIndicator` UIID's fg colour if set (consistent with + /// the animated tab indicator), otherwise the form's title fg. + /// - The arc sits centered horizontally and at `pullToRefreshHeight / 2` + /// from the form's content-pane top. + /// + /// `taskExecuted == true` -> continuous-spin mode (animation + /// registered with the form so it ticks each frame). Pre-threshold + /// the sweep mirrors the user's pull fraction; post-threshold (but + /// pre-release) the sweep is fixed at the full ring. + private long modernSpinStartTime = 0L; + + public void drawModernPullToRefresh(Graphics g, Component cmp, boolean taskExecuted) { + final int height = getPullToRefreshHeight(); + final int scrollY = cmp.getScrollY(); + final int pullDistance = -scrollY; // positive when pulling + if (pullDistance <= 0 && !taskExecuted) { + return; + } + + final int diameter = Display.getInstance().convertToPixels(modernIndicatorDiameterMm()); + final int strokePx = Math.max(1, Display.getInstance().convertToPixels(modernIndicatorStrokeMm())); + final int radius = diameter / 2; + // Center the indicator horizontally and inside the pull region. + int cx = cmp.getAbsoluteX() + cmp.getWidth() / 2; + int cy = cmp.getAbsoluteY() - scrollY - height / 2; + int boxX = cx - radius; + int boxY = cy - radius; + + int sweep; // degrees -- the visible arc length + int startAngle; // degrees -- where the arc starts (0 = 3 o'clock, CCW positive in CN1 Graphics) + if (taskExecuted) { + // Continuous spin: rotate the arc by ~360 deg/sec. + if (modernSpinStartTime == 0L) { + modernSpinStartTime = AnimationTime.now(); + } + long elapsed = AnimationTime.now() - modernSpinStartTime; + startAngle = (int) ((elapsed / 2L) % 360L); + sweep = 280; + // Schedule the next frame -- without this the spinner freezes + // after one paint pass. + Form f = cmp.getComponentForm(); + if (f != null) { + f.registerAnimated(modernSpinnerRepaintAnimation(cmp)); + } + } else { + modernSpinStartTime = 0L; + // Pull fraction 0..1 over the threshold height. + float pull = pullDistance / (float) Math.max(1, height); + float clamped = Math.min(1f, Math.max(0f, pull)); + sweep = (int) (clamped * 330); // grow from 0 to ~full ring + startAngle = 90; // top of the circle + } + + // Material 3 spec: the arc sits on a white circular disc with a + // soft drop shadow. Without the disc the arc is invisible against + // a same-colour backdrop, so paint the disc + shadow first and + // then the arc on top. + final int discPad = Math.max(2, strokePx + 1); + final int discRadius = radius + discPad; + final int discDiameter = discRadius * 2; + final int shadowOffset = Math.max(1, strokePx); + + int oldColor = g.getColor(); + int oldAlpha = g.getAlpha(); + try { + // Soft drop shadow: three concentric translucent black discs + // offset down emulate a small Gaussian blur in pure CN1 + // Graphics (which doesn't expose a real shadow filter). + g.setColor(0x000000); + for (int i = 0; i < 3; i++) { + int extra = 2 - i; + int r = discRadius + extra; + g.setAlpha(28); + g.fillArc(cx - r, cy - r + shadowOffset, 2 * r, 2 * r, 0, 360); + } + // White disc backdrop. + g.setColor(0xffffff); + g.setAlpha(255); + g.fillArc(cx - discRadius, cy - discRadius, discDiameter, discDiameter, 0, 360); + // Arc itself -- N concentric arcs offset by 1px each emulate a + // thick stroke (CN1 Graphics has no stroke-width on drawArc). + g.setColor(modernIndicatorColor()); + for (int i = 0; i < strokePx; i++) { + g.drawArc(boxX + i, boxY + i, diameter - 2 * i, diameter - 2 * i, startAngle, sweep); + } + } finally { + g.setColor(oldColor); + g.setAlpha(oldAlpha); + } + } + + private float modernIndicatorDiameterMm() { + String s = getUIManager().getThemeConstant("pullToRefreshIndicatorDiameterMm", null); + if (s != null) { + float f = Util.toFloatValue(s); + if (f > 0) { + return f; + } + } + return 8f; + } + + private float modernIndicatorStrokeMm() { + String s = getUIManager().getThemeConstant("pullToRefreshIndicatorStrokeMm", null); + if (s != null) { + float f = Util.toFloatValue(s); + if (f > 0) { + return f; + } + } + return 0.6f; + } + + private int modernIndicatorColor() { + // `getComponentStyle(...)` always returns a non-null Style + // (synthesising an empty one when no matching UIID is registered), + // so the null check on the result would be redundant. + Style indicator = getUIManager().getComponentStyle("TabIndicator"); + if (indicator.getFgColor() != 0) { + return indicator.getFgColor(); + } + // Fall back to the form's title foreground, which already tracks + // accent in the modern themes. + Style title = getUIManager().getComponentStyle("Title"); + int titleFg = title.getFgColor(); + if (titleFg != 0) { + return titleFg; + } + return 0x007aff; // iOS blue as ultimate fallback + } + + /// Animation hook used during the continuous-spin phase to request a + /// repaint each EDT cycle without rebuilding state on every frame. + /// Holds no reference to a per-spinner instance -- the animation is + /// registered once and stops itself when `taskExecuted` flips back to + /// false (the pull-to-refresh container repaints from elsewhere when + /// the task finishes). + private Animation modernSpinnerRepaintAnimation(final Component cmp) { + return new Animation() { + @Override + public boolean animate() { + cmp.repaint(cmp.getAbsoluteX(), cmp.getAbsoluteY() - getPullToRefreshHeight(), + cmp.getWidth(), getPullToRefreshHeight()); + return false; + } + + @Override + public void paint(Graphics g) { + // No-op -- the actual paint happens via cmp.repaint above. + } + }; + } + /// {@inheritDoc} @Override public int getPullToRefreshHeight() { + // Modern mode skips the legacy Label/Container stack and uses the + // configured indicator diameter plus a small breathing margin as + // the gesture threshold. + if (getUIManager().isThemeConstant("pullToRefreshModernBool", false)) { + int diameter = Display.getInstance().convertToPixels(modernIndicatorDiameterMm()); + int margin = Display.getInstance().convertToPixels(2f); + return diameter + margin * 2; + } if (pull == null) { BorderLayout bl = new BorderLayout(); bl.setCenterBehavior(BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE); diff --git a/Ports/CLDC11/src/java/lang/Iterable.java b/Ports/CLDC11/src/java/lang/Iterable.java index 66f86db1f6..4c19f8cea0 100644 --- a/Ports/CLDC11/src/java/lang/Iterable.java +++ b/Ports/CLDC11/src/java/lang/Iterable.java @@ -17,6 +17,7 @@ package java.lang; import java.util.Iterator; +import java.util.function.Consumer; /// Objects of classes that implement this interface can be used within a /// `foreach` statement. @@ -32,4 +33,10 @@ public interface Iterable { /// /// An `Iterator` instance. Iterator iterator(); + + /// Performs the given action for each element of the `Iterable`. + /// Stubbed in the CLDC11 subset; the actual implementation comes + /// from the platform's JDK at runtime. + default void forEach(Consumer action) { + } } diff --git a/Ports/CLDC11/src/java/util/Collection.java b/Ports/CLDC11/src/java/util/Collection.java index 55ac391203..ba29f40a84 100644 --- a/Ports/CLDC11/src/java/util/Collection.java +++ b/Ports/CLDC11/src/java/util/Collection.java @@ -17,6 +17,8 @@ package java.util; +import java.util.function.Predicate; + /// `Collection` is the root of the collection hierarchy. It defines operations on /// data collections and the behavior that they will have in all implementations @@ -364,4 +366,11 @@ public interface Collection extends java.lang.Iterable { /// if the type of an element in this `Collection` cannot be /// stored in the type of the specified array. public T[] toArray(T[] array); + + /// Removes all of the elements of this collection that satisfy the + /// given predicate. Stubbed in the CLDC11 subset; the actual + /// implementation comes from the platform's JDK at runtime. + default boolean removeIf(Predicate filter) { + return false; + } } diff --git a/Ports/CLDC11/src/java/util/List.java b/Ports/CLDC11/src/java/util/List.java index 0fc8fa7a29..9c6a02154a 100644 --- a/Ports/CLDC11/src/java/util/List.java +++ b/Ports/CLDC11/src/java/util/List.java @@ -17,6 +17,8 @@ package java.util; +import java.util.function.UnaryOperator; + /// A `List` is a collection which maintains an ordering for its elements. Every /// element in the `List` has an index. Each element can thus be accessed by its @@ -431,4 +433,17 @@ public interface List extends Collection { /// if the type of an element in this `List` cannot be stored /// in the type of the specified array. public T[] toArray(T[] array); + + /// Replaces each element of this list with the result of applying + /// the operator to that element. Stubbed in the CLDC11 subset; + /// the actual implementation comes from the platform's JDK at + /// runtime. + default void replaceAll(UnaryOperator operator) { + } + + /// Sorts this list according to the order induced by the specified + /// `Comparator`. Stubbed in the CLDC11 subset; the actual + /// implementation comes from the platform's JDK at runtime. + default void sort(Comparator c) { + } } diff --git a/Ports/CLDC11/src/java/util/Map.java b/Ports/CLDC11/src/java/util/Map.java index 68bd3283bb..3343f2f359 100644 --- a/Ports/CLDC11/src/java/util/Map.java +++ b/Ports/CLDC11/src/java/util/Map.java @@ -17,6 +17,10 @@ package java.util; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + /// A `Map` is a data structure consisting of a set of keys and values /// in which each key is mapped to a single value. The class of the objects @@ -290,4 +294,83 @@ public static interface Entry { /// /// a collection of the values contained in this map. public Collection values(); + + // ---- Java 8 default methods. ---- + // + // Stubbed in the CLDC11 subset; the actual implementations come + // from the platform's JDK at runtime (Android JDK on Android, + // vm/JavaAPI on ParparVM, the host JDK in the JavaSE simulator). + + /// Returns the value to which the specified key is mapped, or + /// `defaultValue` if this map contains no mapping for the key. + default V getOrDefault(Object key, V defaultValue) { + return null; + } + + /// If the specified key is not already associated with a value (or is + /// mapped to `null`) associates it with the given value and returns + /// `null`, else returns the current value. + default V putIfAbsent(K key, V value) { + return null; + } + + /// Removes the entry for the specified key only if it is currently + /// mapped to the specified value. + default boolean remove(Object key, Object value) { + return false; + } + + /// Replaces the entry for the specified key only if currently mapped + /// to the specified value. + default boolean replace(K key, V oldValue, V newValue) { + return false; + } + + /// Replaces the entry for the specified key only if it is currently + /// mapped to some value. + default V replace(K key, V value) { + return null; + } + + /// Performs the given action for each entry in this map. + default void forEach(BiConsumer action) { + } + + /// Replaces each entry's value with the result of invoking the given + /// function on that entry. + default void replaceAll(BiFunction function) { + } + + /// If the specified key is not already associated with a value (or + /// is mapped to `null`), attempts to compute its value using the + /// given mapping function and enters it into this map unless + /// `null`. + default V computeIfAbsent(K key, Function mappingFunction) { + return null; + } + + /// If the value for the specified key is present and non-null, + /// attempts to compute a new mapping given the key and its current + /// mapped value. + default V computeIfPresent(K key, + BiFunction remappingFunction) { + return null; + } + + /// Attempts to compute a mapping for the specified key and its + /// current mapped value (or `null` if there is no current mapping). + default V compute(K key, + BiFunction remappingFunction) { + return null; + } + + /// If the specified key is not already associated with a value or + /// is associated with `null`, associates it with the given non-null + /// value. Otherwise, replaces the associated value with the result + /// of the given remapping function, or removes if the result is + /// `null`. + default V merge(K key, V value, + BiFunction remappingFunction) { + return null; + } } diff --git a/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicBoolean.java b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicBoolean.java new file mode 100644 index 0000000000..96c912cc84 --- /dev/null +++ b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicBoolean.java @@ -0,0 +1,50 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/// CLDC11 subset stub. Compile-time visible only; the actual runtime +/// implementation comes from the platform (the Android JDK on Android, +/// `vm/JavaAPI` on ParparVM, the host JDK in the JavaSE simulator). +public class AtomicBoolean implements java.io.Serializable { + + public AtomicBoolean(boolean initialValue) { + } + + public AtomicBoolean() { + } + + public final boolean get() { + return false; + } + + public final void set(boolean newValue) { + } + + public final void lazySet(boolean newValue) { + } + + public final boolean getAndSet(boolean newValue) { + return false; + } + + public final boolean compareAndSet(boolean expect, boolean update) { + return false; + } + + public final boolean weakCompareAndSet(boolean expect, boolean update) { + return false; + } +} diff --git a/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicInteger.java b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicInteger.java new file mode 100644 index 0000000000..a20f502e6b --- /dev/null +++ b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicInteger.java @@ -0,0 +1,94 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/// CLDC11 subset stub. Compile-time visible only; the actual runtime +/// implementation comes from the platform (the Android JDK on Android, +/// `vm/JavaAPI` on ParparVM, the host JDK in the JavaSE simulator). +public class AtomicInteger extends Number implements java.io.Serializable { + + public AtomicInteger(int initialValue) { + } + + public AtomicInteger() { + } + + public final int get() { + return 0; + } + + public final void set(int newValue) { + } + + public final void lazySet(int newValue) { + } + + public final int getAndSet(int newValue) { + return 0; + } + + public final boolean compareAndSet(int expect, int update) { + return false; + } + + public final boolean weakCompareAndSet(int expect, int update) { + return false; + } + + public final int getAndIncrement() { + return 0; + } + + public final int getAndDecrement() { + return 0; + } + + public final int getAndAdd(int delta) { + return 0; + } + + public final int incrementAndGet() { + return 0; + } + + public final int decrementAndGet() { + return 0; + } + + public final int addAndGet(int delta) { + return 0; + } + + @Override + public int intValue() { + return 0; + } + + @Override + public long longValue() { + return 0L; + } + + @Override + public float floatValue() { + return 0f; + } + + @Override + public double doubleValue() { + return 0.0; + } +} diff --git a/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicLong.java b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicLong.java new file mode 100644 index 0000000000..32c24cdd9b --- /dev/null +++ b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicLong.java @@ -0,0 +1,94 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/// CLDC11 subset stub. Compile-time visible only; the actual runtime +/// implementation comes from the platform (the Android JDK on Android, +/// `vm/JavaAPI` on ParparVM, the host JDK in the JavaSE simulator). +public class AtomicLong extends Number implements java.io.Serializable { + + public AtomicLong(long initialValue) { + } + + public AtomicLong() { + } + + public final long get() { + return 0L; + } + + public final void set(long newValue) { + } + + public final void lazySet(long newValue) { + } + + public final long getAndSet(long newValue) { + return 0L; + } + + public final boolean compareAndSet(long expect, long update) { + return false; + } + + public final boolean weakCompareAndSet(long expect, long update) { + return false; + } + + public final long getAndIncrement() { + return 0L; + } + + public final long getAndDecrement() { + return 0L; + } + + public final long getAndAdd(long delta) { + return 0L; + } + + public final long incrementAndGet() { + return 0L; + } + + public final long decrementAndGet() { + return 0L; + } + + public final long addAndGet(long delta) { + return 0L; + } + + @Override + public int intValue() { + return 0; + } + + @Override + public long longValue() { + return 0L; + } + + @Override + public float floatValue() { + return 0f; + } + + @Override + public double doubleValue() { + return 0.0; + } +} diff --git a/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicReference.java b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicReference.java new file mode 100644 index 0000000000..28176c5b15 --- /dev/null +++ b/Ports/CLDC11/src/java/util/concurrent/atomic/AtomicReference.java @@ -0,0 +1,50 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/// CLDC11 subset stub. Compile-time visible only; the actual runtime +/// implementation comes from the platform (the Android JDK on Android, +/// `vm/JavaAPI` on ParparVM, the host JDK in the JavaSE simulator). +public class AtomicReference { + + public AtomicReference() { + } + + public AtomicReference(V initialValue) { + } + + public final boolean compareAndSet(V expect, V update) { + return false; + } + + public V get() { + return null; + } + + public final V getAndSet(V newValue) { + return null; + } + + public final void lazySet(V newValue) { + } + + public final boolean weakCompareAndSet(V expect, V update) { + return false; + } + + public final void set(V newValue) { + } +} diff --git a/Ports/CLDC11/src/java/util/function/BiFunction.java b/Ports/CLDC11/src/java/util/function/BiFunction.java new file mode 100644 index 0000000000..92c2bccaf7 --- /dev/null +++ b/Ports/CLDC11/src/java/util/function/BiFunction.java @@ -0,0 +1,5 @@ +package java.util.function; + +public interface BiFunction { + R apply(T t, U u); +} diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index 03b1e85216..ae3b0c3dd4 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res index 619fda6e54..67755d9d7a 100644 Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ diff --git a/docs/developer-guide/Animations.asciidoc b/docs/developer-guide/Animations.asciidoc index 44065c4069..5be49d24bf 100644 --- a/docs/developer-guide/Animations.asciidoc +++ b/docs/developer-guide/Animations.asciidoc @@ -374,6 +374,45 @@ forms so the transition will be able to find them: include::../demos/common/src/main/java/com/codenameone/developerguide/animations/MorphTransitionDemo.java[tag=morphTransition,indent=0] ---- +===== Snapshot mode + +By default `MorphTransition` paints both endpoints by re-rendering the live +source / destination components every frame at the interpolated bounds. +That works well when the source is fully visible -- a card in the body +of one form morphing to a card in the next. + +It runs into edge cases when the source lives inside a scrolling +container that has children extending past the source's bounds, or +when the source has dynamic content (a video frame, a `BrowserComponent`, +a custom-painted background) that should be visually frozen for the +duration of the animation. The legacy live-paint path can leak +off-viewport pixels into the morph because the layered pane that holds +the source during the animation doesn't carry the original parent's clip. + +To opt into the image-snapshot path call `snapshotMode(true)` on the +builder: + +[source,java] +---- +MorphTransition morph = MorphTransition.create(300) + .snapshotMode(true) + .morph("card"); +nextForm.setTransitionInAnimator(morph); +nextForm.show(); +---- + +`snapshotMode(true)` captures each `(source, dest)` pair as a clipped +`Image` at `initTransition()`, then the tween draws those images at the +interpolated bounds rather than re-painting the live components. +Off-viewport children of the source are clipped at capture time (the +image's own bounds are the clip), so they can't leak into the morph. + +The default `MorphTransition` behavior (live paint, no snapshots) is +unchanged for back-compat. Use snapshot mode opportunistically when +the live-paint output exhibits the off-viewport leak, or when the +source's children produce frame-by-frame visual change you want to +freeze. + ==== SwipeBackSupport iOS7+ allows swiping back one form to the previous form, Codename One has an API to enable back swipe transition: diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc index eb7d0e481f..7d352fede4 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -222,7 +222,7 @@ every accepted `@Bindable` class. At app start: the binding still resolves after the pass. * On **JavaSE** `JavaSEPort#postInit` loads the bootstrap via `Class.forName("cn1app.BinderBootstrap")` -- the unobfuscated - class-loading path. + classloader path. Projects with no `@Bindable` classes produce no bootstrap; the build server probe falls through and the registry stays empty. diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc index 0d12c3da48..a3c97a77f1 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -151,7 +151,7 @@ At app start: direct symbol reference stays valid after the pass. * On the **JavaSE simulator and desktop run**, `JavaSEPort#postInit` loads the bootstrap via `Class.forName("cn1app.MapperBootstrap")`. - Class-loading is the legitimate path here -- JavaSE runs unobfuscated + The classloader is the legitimate path here -- JavaSE runs unobfuscated and the same `Class.forName` pattern is used by the @Route dispatcher. Projects that ship no `@Mapped` classes produce no bootstrap, the build diff --git a/docs/developer-guide/Maven-Appendix-Goals.adoc b/docs/developer-guide/Maven-Appendix-Goals.adoc index 5333ea6be0..7492d8f995 100644 --- a/docs/developer-guide/Maven-Appendix-Goals.adoc +++ b/docs/developer-guide/Maven-Appendix-Goals.adoc @@ -23,6 +23,8 @@ include::appendix_goal_generate_gui_sources.adoc[] include::appendix_goal_generate_native_interfaces.adoc[] +include::appendix_goal_generate_openapi.adoc[] + include::appendix_goal_guibuilder.adoc[] include::appendix_goal_generate_archetype.adoc[] diff --git a/docs/developer-guide/Miscellaneous-Features.asciidoc b/docs/developer-guide/Miscellaneous-Features.asciidoc index b463d23e25..daebcb7fb2 100644 --- a/docs/developer-guide/Miscellaneous-Features.asciidoc +++ b/docs/developer-guide/Miscellaneous-Features.asciidoc @@ -460,7 +460,7 @@ cn1_icon_[_].png Supply a square source image at least 432×432 pixels (the largest size emitted for Android adaptive icons); the build resizes it to every target density. The default app icon continues to be controlled by your `codenameone_settings.properties` file and is used whenever the device locale doesn't match any of the localized variants. At runtime the builders look for a `_` match first, then fall back to a bare `` match. Providing both (for example `cn1_icon_en.png` plus `cn1_icon_en_GB.png`) lets you give British users a country-specific icon while every other English locale still receives the generic English icon. -===== Android behaviour +===== Android behavior On Android the build generates locale-qualified drawable resources at every density so the platform picks the right icon automatically based on the device's current locale: @@ -471,7 +471,7 @@ No code changes are required—Android's resource framework switches icons when When you supply a region-qualified icon (such as `cn1_icon_ar_AE.png`) without a matching language-only variant, the build also emits the *default* (non-localized) icon into `drawable-/` and the matching `mipmap-*-/` directories. This barrier is required because Android's resource resolver (API 24+) walks every child of the parent locale when it can't find an exact or parent-language match, and would otherwise pick `ar-rAE` for, say, an `ar-PK` device. The barrier short-circuits that lookup so only devices whose region matches the supplied variant receive the localized icon. If you also ship a language-only file (for example `cn1_icon_ar.png`) it's used as the barrier instead, so you keep full control of the fallback icon for Arabic speakers outside AE. -===== iOS behaviour +===== iOS behavior iOS doesn't localize launcher icons natively, so Codename One wires up https://developer.apple.com/documentation/uikit/uiapplication/2806818-setalternateiconname[alternate app icons] for you: @@ -480,7 +480,7 @@ iOS doesn't localize launcher icons natively, so Codename One wires up https://d * The `CodenameOne_GLAppDelegate` is patched to call `-[UIApplication setAlternateIconName:completionHandler:]` at launch. The delegate reads `[NSLocale preferredLanguages]`, tries the full `_` key first, then falls back to the language-only key, and clears the alternate icon (reverting to the default) if no variant matches. * The injection is idempotent and runs before the `ios.afterFinishLaunching` hook, so any custom code you supply via that build hint is unaffected. -NOTE: IOS displays a system alert the first time an app switches to an alternate icon. This is platform-standard behaviour—Codename One can't suppress it. +NOTE: IOS displays a system alert the first time an app switches to an alternate icon. This is platform-standard behavior—Codename One can't suppress it. ===== Tips and troubleshooting @@ -1201,9 +1201,55 @@ hi.getContentPane().addPullToRefresh(() -> { hi.show(); ---- +`setPullToRefresh(Runnable)` is provided as a more discoverable alias for the +same single-task slot -- both names route to the same field, and a second +call replaces whatever runnable was previously registered. + .Pull to refresh demo image::img/pull-to-refresh.png[Simple pull to refresh demo,scaledwidth=20%] +==== Modern arc-spinner pull-to-refresh + +The legacy pull-to-refresh visual is the classic "rotating arrow + text" +stack. Modern Material 3 / iOS apps instead show a thin circular arc +that grows as the user pulls and spins once the task fires. This is +shipped as an opt-in path via the `pullToRefreshModernBool` theme +constant; the iOS Modern and Android Material themes enable it by +default so apps shipping against those themes get the spec-accurate +look without any extra wiring. + +To enable in a custom theme: + +[source,css] +---- +#Constants { + pullToRefreshModernBool: true; + pullToRefreshIndicatorDiameterMm: 8; /* 8mm circle */ + pullToRefreshIndicatorStrokeMm: "0.6"; /* 0.6mm stroke thickness */ +} + +TabIndicator { + /* The pull-to-refresh arc shares its color with the animated tab + indicator -- both follow the brand's accent. */ + color: #007aff; + background-color: transparent; + padding: 0; + margin: 0; +} +---- + +Behavior: + +* During the pull gesture the arc sweep grows from 0° to ~330° + proportional to how far the user has pulled past the threshold. +* When the refresh task fires the arc rotates continuously at ~360°/sec + until the task completes. +* All sizing is in millimetres so the indicator stays physical-size-accurate + across device densities; no rasterized images involved. + +The legacy `addPullToRefresh` API is unchanged -- the same `Runnable` is +invoked at the same moment; only the visual rendering differs. + === Running 3rd party apps using display's execute The https://www.codenameone.com/javadoc/com/codename1/ui/Display.html[Display] class's `execute` method allows you to invoke a URL which is bound to a particular application. diff --git a/docs/developer-guide/Native-Themes.asciidoc b/docs/developer-guide/Native-Themes.asciidoc index ccf78e6ab3..ffcad0cc41 100644 --- a/docs/developer-guide/Native-Themes.asciidoc +++ b/docs/developer-guide/Native-Themes.asciidoc @@ -7,7 +7,7 @@ ramps, and state-specific styles are all reachable from your own `theme.css` and from runtime API. The legacy iOS 7 (iOS) and Holo Light (Android) themes remain the -default so existing apps see no behaviour change. +default so existing apps see no behavior change. === Selecting a theme @@ -417,7 +417,7 @@ differently from the other. They're still safe to override; you just want to know which platform's screen the override is going to land on. -iOS-only behaviour: +iOS-only behavior: * `Toolbar`, `TitleArea`, `Title` paint over the status-bar area. iOS reserves room above for the notch / status bar; in your @@ -428,14 +428,14 @@ iOS-only behaviour: ramp + chevron). Compare against Material 3's denser list-item pattern. -Android-only behaviour: +Android-only behavior: * `Toolbar` doesn't paint over the system status bar; the native Android status bar handles that. * `Tabs` use Material 3 top tabs (flat, underline-by-color). iOS modern renders Tabs as a bottom-anchored pill group via the `tabPlacementInt` constant - that constant is intentionally only - set in the iOS theme so behaviour stays consistent on each + set in the iOS theme so behavior stays consistent on each platform. === Switching CheckBox / RadioButton glyphs @@ -500,6 +500,37 @@ top). |`@tabsFillRowsBool`, `@tabsGridBool` |Distribute tabs evenly across the bar. +|`@tabsAnimatedIndicatorBool` +|`true` paints a thin colored underline below the currently selected +tab and tweens its `x` / `width` between tabs on selection change. Both +modern themes set this to `true`. Off in the framework default; opt-in +for legacy themes by setting it explicitly. + +|`@tabsAnimatedIndicatorDurationInt` +|Duration of the indicator tween in milliseconds (default `200`, +matching Material 3's `NavigationBar` spec). + +|`@tabsAnimatedIndicatorThicknessMm` +|Indicator underline thickness in millimetres (default `1`). + +|`@pullToRefreshModernBool` +|`true` switches the pull-to-refresh visual from the legacy +rotating-arrow + text Label stack to a thin circular arc spinner +painted directly via `Graphics.drawArc`. The arc sweep grows from 0° +to ~330° proportional to the user's pull, then spins continuously +once the refresh task fires. Both modern themes set this to `true`. +Color comes from the `TabIndicator` UIID's fg (consistent with the +animated tab indicator), falling back to the Title fg if unset. + +|`@pullToRefreshIndicatorDiameterMm` +|Outer diameter of the modern arc spinner in millimetres (default `8`). +Drives the gesture threshold too -- the user must pull this distance +plus a small margin to fire the refresh task. + +|`@pullToRefreshIndicatorStrokeMm` +|Stroke thickness of the modern arc spinner in millimetres (default +`0.6`). + |`@darkModeBool` |`true` enables `$DarkUIID` resolution when the app is in dark mode. Both modern themes set this; user themes that want dark diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index 6ad3204325..5513a389c4 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -392,7 +392,7 @@ This will show the dialog on the right-hand side of the screen, which is pretty NOTE: The `InteractionDialog` can be shown at absolute or popup locations. This is inherent to its use case which is "non-blocking." When using this component you need to be aware of its location. -To make popup behaviour feel natural on touch devices you can call `setDisposeWhenPointerOutOfBounds(true)` so the dialog automatically dismisses as soon as the user taps outside the title or content area. Internally the dialog listens for pointer pressed/released events and will call `dispose()` for you when the interaction happens beyond its bounds, so you no longer need to wire that logic manually. +To make popup behavior feel natural on touch devices you can call `setDisposeWhenPointerOutOfBounds(true)` so the dialog automatically dismisses as soon as the user taps outside the title or content area. Internally the dialog listens for pointer pressed/released events and will call `dispose()` for you when the interaction happens beyond its bounds, so you no longer need to wire that logic manually. By default the dialog is placed on the form's layered pane, but you can switch between the global layered pane and form-specific layered pane using `setFormMode(boolean)`. Setting form mode to `true` keeps the dialog coupled with the showing form even when the global layered pane is used elsewhere in your app. @@ -2467,6 +2467,52 @@ image::img/components-tabs-swipe2.png[Swipeable Tabs with an iOS carousel effect NOTE: Notice that you used `setRadioButtonImages` to explicitly set the radio button images to the look you want for the carousel. +==== Animated tab indicator + +Modern Material 3 (`NavigationBar`) and iOS 26 tab bars animate a small +underline between tabs when the user changes selection. `Tabs` ships +this as an opt-in feature gated by the `tabsAnimatedIndicatorBool` +theme constant; the iOS Modern and Android Material themes turn it on +by default, so apps shipping against those themes get the effect for +free. + +To enable in a custom theme: + +[source,css] +---- +#Constants { + tabsAnimatedIndicatorBool: true; + tabsAnimatedIndicatorDurationInt: 200; /* tween duration in ms */ + tabsAnimatedIndicatorThicknessMm: 1; /* underline thickness */ +} + +TabIndicator { + /* The indicator picks up its color from this UIID's fg. If the + UIID isn't defined or has fgColor == 0, the indicator falls + back to the currently-selected tab's fgColor. */ + color: #007aff; + background-color: transparent; + padding: 0; + margin: 0; +} +---- + +To toggle programmatically (e.g. add it to a Tabs instance whose theme +hasn't enabled it): + +[source,java] +---- +Tabs tabs = new Tabs(); +tabs.setAnimatedIndicator(true); +---- + +The indicator animates its `x` / `width` from the previously selected +tab's bounds to the new selection's bounds using a `Motion.createEaseInOutMotion` +over the configured duration (200ms default, matching Material 3's +spec). Rapid double-taps start the animation from the *current +interpolated position* rather than from a stale baseline, so the +indicator chains cleanly. + [[mediamanager-section]] === MediaManager & MediaPlayer diff --git a/docs/developer-guide/appendix_goal_generate_openapi.adoc b/docs/developer-guide/appendix_goal_generate_openapi.adoc new file mode 100644 index 0000000000..c210cf9bec --- /dev/null +++ b/docs/developer-guide/appendix_goal_generate_openapi.adoc @@ -0,0 +1,140 @@ +=== Generate OpenAPI client (`generate-openapi`) + +Generates a typed Codename One client from an OpenAPI 3.x JSON +specification. Writes one `@Mapped` record (Java 17+) or class (Java +8 target) per `components.schemas` entry and one +`@RestClient`-annotated interface per OpenAPI tag. The generated +files land in `common/src/main/java` so the project owns the +contract; the matching networking implementation is emitted into +`common/target/generated-sources` by the build-time annotation +processor so the project source stays clean. + +The mojo is paired with the existing `process-annotations` pipeline: +identical schemas across operations collapse to one record/class, and +operationIds become interface methods that resolve at runtime via a +`com.codename1.io.rest.RestClients` registry populated by the generated +bootstrap class (the same splice pattern as `@Mapped` mappers). + +==== Usage example + +[source, bash] +---- +mvn -pl common cn1:generate-openapi \ + -Dcn1.openapi.spec=petstore.json \ + -Dcn1.openapi.basePackage=com.example.petstore +---- + +Configuration: + +[cols="1,3", options="header"] +|=== +| Property | Description + +| `-Dcn1.openapi.spec=PATH` +| Local file path or URL of the OpenAPI 3.x JSON document, for example +`petstore.json` or `https://petstore3.swagger.io/api/v3/openapi.json`. +YAML isn't supported -- convert with `yq` upstream. + +| `-Dcn1.openapi.basePackage=PKG` +| Java package the generated sources are written under. Records / classes +go under `.model`; the `@RestClient` interfaces go under +``. + +| `-Dcn1.openapi.outputDirectory=DIR` (optional) +| Defaults to `${project.basedir}/src/main/java`. + +| `-Dcn1.openapi.overwrite=false` (optional) +| Defaults to `true`. Set to `false` to preserve user edits to existing +files (only missing files are written). +|=== + +==== Generated output + +For the Swagger Petstore reference spec the goal emits, under +`common/src/main/java`: + +[listing] +---- +com/example/petstore/ + PetApi.java // @RestClient interface, methods addPet, updatePet, + // findPetsByStatus, getPetById, deletePet, ... + StoreApi.java // @RestClient interface, methods getInventory, + // placeOrder, getOrderById, deleteOrder + UserApi.java // @RestClient interface +com/example/petstore/model/ + Pet.java // @Mapped record (Java 17+) or class (Java 8) + Order.java + User.java + Category.java + Tag.java +---- + +Each `@RestClient` interface method is annotated with the HTTP verb +and path; parameters are annotated `@Path` / `@Query` / `@Header` / +`@Body` so the processor knows how to assemble the `Rest` call. The +emitted method shape: + +[source, java] +---- +@RestClient +public interface PetApi { + + @GET("/pet/{petId}") + void getPetById(@Path("petId") Long petId, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + @POST("/pet") + void addPet(@Body com.example.petstore.model.Pet body, + @Header("Authorization") String bearerToken, + OnComplete> callback); + + static PetApi of(String baseUrl) { + return RestClients.create(PetApi.class, baseUrl); + } +} +---- + +Model types are emitted as fully qualified names (the API interface +lives in `` and models under `.model`) so +the generator never needs to track imports or worry about +collisions between an API class name and a same-named model. + +Call sites use the static factory: + +[source, java] +---- +PetApi api = PetApi.of("https://petstore3.swagger.io/api/v3"); +api.getPetById(123L, "MY_TOKEN", response -> { + if (response.getResponseCode() == 200) { + Pet pet = response.getResponseData(); + renderPet(pet); + } +}); +---- + +The `ApiImpl` class that actually performs the HTTP call lives +in `target/generated-sources` -- the project source never references +it directly. The build server probes the project zip for the +generated `cn1app.RestClientBootstrap` and splices the registry +wiring in, mirroring the existing `cn1app.MapperBootstrap` pattern. + +==== Scope + +* HTTP verbs: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`. +* Parameter locations: `path`, `query`, `header`, `cookie`. Multiple +`cookie` parameters on the same operation are joined into a single +`Cookie: a=1; b=2` request header. +* Request bodies: `application/json` -- serialized via +`Mappers.toJson(body)` before being attached. +* Response schemas: `$ref` resolution, primitives (`string` / +`number` / `integer` / `boolean`), arrays, object schemas. +`oneOf` / `anyOf` / `allOf` collapse to `Object` -- callers cast. +* Schema unification: two `components.schemas` entries with identical +property shapes collapse to a single record/class to avoid an +explosion of duplicates. +* Authentication: bearer token is exposed as a `@Header("Authorization") +String bearerToken` parameter on every operation. API-key auth +declared in the spec is emitted as `@Header` or `@Query` like any +other parameter; basic auth and OAuth bearer tokens travel through +the same `bearerToken` slot. diff --git a/docs/developer-guide/graphics.asciidoc b/docs/developer-guide/graphics.asciidoc index 84fc9a4bc9..9376998036 100644 --- a/docs/developer-guide/graphics.asciidoc +++ b/docs/developer-guide/graphics.asciidoc @@ -1379,6 +1379,47 @@ you would need to convert the processed image back to an encoded image so it can If you need to download the file instantly and not wait for the image to appear before download initiates you can explicitly invoke the `fetch()` method which will asynchronously fetch the image from the network. Notice that the downloading will still take time so the placeholder is still required. +===== Authenticated image URLs — `RequestDecorator` + +The standard `createToStorage` path doesn't expose the underlying +`ConnectionRequest`, so attaching an `Authorization` header (or any +other custom header / cookie / timeout) requires the `RequestDecorator` +hook. Two ways to install one: + +[source,java] +---- +// Global default -- applied to every URLImage download from this point on. +// The most common case is "all our images sit behind the same bearer +// token", which has its own shorthand: +URLImage.setDefaultBearerToken(Preferences.get("auth.token", null)); + +// Or the explicit form, which can attach any header / cookie / timeout: +URLImage.setDefaultRequestDecorator(req -> + req.addRequestHeader("Authorization", "Bearer " + token)); +---- + +For per-image overrides (for example one endpoint needs an extra API-version +header on top of the global bearer token), use the +`createToStorage(placeholder, key, url, adapter, RequestDecorator)` +overload. Per-instance decorators run *after* the global default so they +can override or augment whatever the default set: + +[source,java] +---- +URLImage profilePic = URLImage.createToStorage( + placeholder, + "profile-" + userId, + baseUrl + "/users/" + userId + "/picture", + URLImage.RESIZE_SCALE_TO_FILL, + req -> req.addRequestHeader("X-API-Version", "2")); +---- + +When a decorator is installed (global or per-call), `URLImage` skips the +default `Util.downloadImageToStorage` path and builds a +`ConnectionRequest` inline so the decorator can inspect / mutate it +before it's queued. The legacy path remains the default for back-compat +when no decorator is set. + ===== Mask adapter A `URLImage` can be created with a mask adapter to apply an effect to an image. This allows you to round downloaded images or apply any sort of masking for example: you can adapt the round mask code above as such: diff --git a/docs/developer-guide/io.asciidoc b/docs/developer-guide/io.asciidoc index 47d5883eb4..94958e3ab1 100644 --- a/docs/developer-guide/io.asciidoc +++ b/docs/developer-guide/io.asciidoc @@ -873,6 +873,45 @@ An alternative approach is to use the static data parse() method of the `JSONPar Notice that a static version of the method is used! The callback object is an instance of the `JSONParseCallback` interface, which includes many methods. These methods are invoked by the parser to show internal parser states, this is like the way traditional XML SAX event parsers work. +===== Writing JSON — `JSONWriter` + +For typed DTO serialization use the `@Mapped` annotation framework +(see `<>`) plus `Mappers.toJson(...)`. For ad-hoc +maps / lists where a build-time `Mapper` would be overkill, the +`com.codename1.io.JSONWriter` class is the complement of `JSONParser`: + +[source,java] +---- +// One-shot encoding of any Map / List / String / Number / Boolean / null tree +String json = JSONWriter.toJson(Map.of("name", "ada", "values", List.of(1, 2, 3))); + +// Streaming variants for large outputs (UTF-8) +JSONWriter.toJson(value, writer); +JSONWriter.toJson(value, outputStream); +---- + +For tiny request bodies the fluent builders are usually faster to read +than a `Map` literal: + +[source,java] +---- +String body = JSONWriter.object() + .put("email", email) + .put("password", password) + .toJson(); + +String coords = JSONWriter.array() + .add(JSONWriter.object().put("lat", 37.7749).put("lng", -122.4194)) + .add(JSONWriter.object().put("lat", 51.5074).put("lng", -0.1278)) + .toJson(); +---- + +Strings are double-quoted with the standard backslash escapes for `"`, +`\`, `\n`, `\r`, `\t`, `\b`, `\f`, and control chars below `0x20` are +emitted as `+\u00xx+`. The writer doesn't pretty-print; if you need +indented output, run the result through an external formatter at debug +time. + ===== XML parsing The https://www.codenameone.com/javadoc/com/codename1/xml/XMLParser.html[XMLParser] started its life as an HTML parser built for displaying mobile HTML. That usage has since been deprecated but the parser can still parse many HTML pages and is "loose" in verification. This is both good and bad as the parser will work with invalid data without complaining. @@ -1349,6 +1388,78 @@ Some highlights that are easy to miss: * `.cacheMode(...)` and `.postParameters(...)` expose the same knobs as `ConnectionRequest`, keeping you in the fluent API even for advanced tweaks. +==== Top-level JSON arrays — `fetchAsJsonList` + +When the endpoint returns a top-level JSON array (for example `+[{...}, {...}]+`), +use `fetchAsJsonList` rather than `fetchAsJsonMap`. The underlying +`JSONParser` wraps top-level arrays under a synthetic `"root"` key; +`fetchAsJsonList` unwraps that envelope for you so the callback receives +the array directly: + +[source,java] +---- +Rest.get("https://api.example.com/items") + .header("Authorization", "Bearer " + token) + .acceptJson() + .fetchAsJsonList(response -> { + List items = response.getResponseData(); + renderItems(items); + }); +---- + +There is no separate builder for the ambiguous "object or array" case: +when the response shape isn't known up-front, use `fetchAsJsonMap` and +branch on `data.get("root") instanceof List`. + +==== Typed responses — `fetchAsMapped` and `fetchAsMappedList` + +`Rest.fetchAsJsonMap` returns a generic `Map` that the caller +casts and inspects key by key. Once your DTOs are `@Mapped`-annotated +(see `<>`), `fetchAsMapped` returns the typed object +directly: + +[source,java] +---- +// Model +@Mapped +public class Pet { + @JsonProperty("id") public long id; + @JsonProperty("name") public String name; + @JsonProperty("photoUrls") public List photoUrls; +} + +// Call site +Rest.get(baseUrl + "/pet/" + petId) + .header("Authorization", "Bearer " + token) + .acceptJson() + .fetchAsMapped(Pet.class, response -> { + Pet pet = response.getResponseData(); // already typed -- no Map casts + renderPet(pet); + }); +---- + +For endpoints that return a list of DTOs, use `fetchAsMappedList`: + +[source,java] +---- +Rest.get(baseUrl + "/albums") + .header("Authorization", "Bearer " + token) + .acceptJson() + .fetchAsMappedList(Album.class, response -> { + List albums = response.getResponseData(); + renderAlbums(albums); + }); +---- + +Both builders fall back to the untyped `fetchAsJsonMap` / `fetchAsJsonList` +path internally and then route every map element through the build-time +mapper that the `@Mapped` annotation processor generated. If a mapper isn't +registered for the requested class (typical cause: the +`process-annotations` Mojo didn't run on the class, or the class isn't +`@Mapped`-annotated), the callback completes with `null` data -- so always +inspect `response.getResponseCode()` rather than `response.getResponseData()` +to differentiate a server error response from the missing-mapper case. + The code in the kitchen sink webservice sample was updated to use this API. The result is shorter and more readable without sacrificing anything. ==== Rest in practice - twilio diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index fb2bb66105..8ee0cc7862 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -282,6 +282,14 @@ untarring url intrinsics obfuscator +unobfuscated +Petstore +Swagger +Api +tween +tweens +tweened +tweening resizability movability Thaana diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 5ecf26f9d5..7eb6e7356f 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -65,7 +65,10 @@ ${project.groupId} codenameone-core ${project.version} - test + ${project.groupId} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java new file mode 100644 index 0000000000..c8ccd836aa --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/GenerateOpenApiMojo.java @@ -0,0 +1,858 @@ +/* + * 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.io.JSONParser; + +import org.apache.maven.plugin.AbstractMojo; +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 org.apache.maven.project.MavenProject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// Generates user-edited Codename One REST client sources from an +/// OpenAPI 3.x JSON specification. +/// +/// Invocation: +/// +/// ``` +/// mvn cn1:generate-openapi -Dcn1.openapi.spec=petstore.json \ +/// -Dcn1.openapi.basePackage=com.example.petstore +/// ``` +/// +/// (You can also pass the two as positional arguments after the goal name -- +/// when present they are read off `pluginContext` / system properties -- +/// see the developer-guide appendix for the user-facing form.) +/// +/// Outputs: +/// +/// - `.model.` -- one `@Mapped` record (Java 17+ target) +/// or class (Java 8 target) per `components.schemas` entry, with +/// `@JsonProperty` carrying the original spec name when sanitization +/// renames a Java identifier. +/// - `.Api` -- one `@RestClient`-annotated interface per +/// OpenAPI tag, with one method per operation. Each method's parameters +/// come in the order: path params, query params, header params, body, +/// bearer-token header, OnComplete callback. A `static of(String +/// baseUrl)` factory wires the interface to the runtime registry. +/// +/// Identical schemas (same property name + Java type list, same order) +/// collapse onto a single record/class to avoid an explosion of duplicates. +@Mojo(name = "generate-openapi", + defaultPhase = LifecyclePhase.NONE, + requiresProject = true, + threadSafe = true) +public class GenerateOpenApiMojo extends AbstractMojo { + + @Parameter(defaultValue = "${project}", readonly = true) + private MavenProject project; + + /// Path or URL to the OpenAPI JSON spec. YAML is not supported -- use + /// `yq` upstream. Override via `-Dcn1.openapi.spec=...`. + @Parameter(property = "cn1.openapi.spec") + private String spec; + + /// Java base package the generated sources are emitted under. Override + /// via `-Dcn1.openapi.basePackage=...`. + @Parameter(property = "cn1.openapi.basePackage") + private String basePackage; + + /// Output directory for the generated sources. Defaults to + /// `${project.basedir}/src/main/java` because the emitted code is + /// user-edited (records / classes / @RestClient interfaces live in the + /// project's source tree, not under `target/generated-sources`). + @Parameter(property = "cn1.openapi.outputDirectory", + defaultValue = "${project.basedir}/src/main/java") + private File outputDirectory; + + /// When `true` (default) existing files at the destination are + /// overwritten. Pass `-Dcn1.openapi.overwrite=false` to keep your + /// hand-edits and only emit missing files. + @Parameter(property = "cn1.openapi.overwrite", defaultValue = "true") + private boolean overwrite; + + /// First positional argument fallback. Maven CLI doesn't expose + /// positional goal arguments directly so we accept the spec / package + /// via system properties (`-Dcn1.openapi.spec=...`, + /// `-Dcn1.openapi.basePackage=...`) or via inline `` configuration + /// in the POM. Documented alternatively in the developer guide. + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + String effectiveSpec = effectiveSpec(); + String effectivePackage = effectiveBasePackage(); + if (effectiveSpec == null || effectiveSpec.length() == 0) { + throw new MojoFailureException( + "No OpenAPI spec supplied. Pass -Dcn1.openapi.spec= " + + "or configure in the plugin block."); + } + if (effectivePackage == null || effectivePackage.length() == 0) { + throw new MojoFailureException( + "No base package supplied. Pass -Dcn1.openapi.basePackage= " + + "or configure in the plugin block."); + } + + if (effectiveSpec.endsWith(".yaml") || effectiveSpec.endsWith(".yml")) { + throw new MojoFailureException( + "OpenAPI YAML is not supported. Convert with `yq -o json " + effectiveSpec + + " > spec.json` and re-run the goal against the JSON output."); + } + + Map document; + try { + document = loadSpec(effectiveSpec); + } catch (IOException ioe) { + throw new MojoFailureException("Could not load OpenAPI spec at " + + effectiveSpec + ": " + ioe.getMessage(), ioe); + } + + int target = detectJavaTarget(); + boolean emitRecords = target >= 17; + getLog().info("cn1:generate-openapi target=" + target + " emitRecords=" + emitRecords + + " basePackage=" + effectivePackage); + + Generator gen = new Generator(document, effectivePackage, outputDirectory, overwrite, + emitRecords, getLog()); + try { + gen.run(); + } catch (IOException ioe) { + throw new MojoExecutionException("Failed to write generated sources: " + + ioe.getMessage(), ioe); + } + } + + private String effectiveSpec() { + if (spec != null && spec.length() > 0) return spec; + return System.getProperty("cn1.openapi.spec"); + } + + private String effectiveBasePackage() { + if (basePackage != null && basePackage.length() > 0) return basePackage; + return System.getProperty("cn1.openapi.basePackage"); + } + + private int detectJavaTarget() { + // Prefer maven.compiler.release if present, otherwise maven.compiler.target. + String release = null, targetProp = null; + if (project != null && project.getProperties() != null) { + release = project.getProperties().getProperty("maven.compiler.release"); + targetProp = project.getProperties().getProperty("maven.compiler.target"); + } + if (release == null) release = System.getProperty("maven.compiler.release"); + if (targetProp == null) targetProp = System.getProperty("maven.compiler.target"); + return parseJavaVersion(release != null ? release : targetProp); + } + + /// Parses a `maven.compiler.target` style version. Returns `8` for + /// `1.8` or null inputs so callers default to the classic POJO path. + static int parseJavaVersion(String s) { + if (s == null) return 8; + s = s.trim(); + if (s.length() == 0) return 8; + if (s.startsWith("1.")) s = s.substring(2); + // Strip qualifiers like "17-LTS" / "21-ea". + int i = 0; + while (i < s.length() && Character.isDigit(s.charAt(i))) i++; + if (i == 0) return 8; + try { + return Integer.parseInt(s.substring(0, i)); + } catch (NumberFormatException e) { + return 8; + } + } + + /// Loads the spec document. Supports `http://`, `https://`, and local + /// file paths. JSON only -- YAML is rejected by `execute`. + static Map loadSpec(String specLocation) throws IOException { + Reader reader; + if (specLocation.startsWith("http://") || specLocation.startsWith("https://")) { + URL url = new URL(specLocation); + InputStream is = url.openStream(); + reader = new InputStreamReader(is, StandardCharsets.UTF_8); + } else { + File f = new File(specLocation); + if (!f.exists()) { + throw new IOException("OpenAPI spec not found: " + specLocation); + } + reader = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8); + } + try { + return new JSONParser().parseJSON(reader); + } finally { + reader.close(); + } + } + + // ---------------------------------------------------------------- + // Generator + // ---------------------------------------------------------------- + + /// Stateful generator -- parses the spec, accumulates schema + API + /// models, then writes the output files. Package-private for direct + /// unit testing without spinning up a Maven session. + static final class Generator { + private final Map spec; + private final String basePackage; + private final String modelPackage; + private final File outputDir; + private final boolean overwrite; + private final boolean emitRecords; + private final org.apache.maven.plugin.logging.Log log; + private final Map schemas; + /// Tag -> list of operations. + private final TreeMap> opsByTag = new TreeMap>(); + /// Schema name -> SchemaInfo accumulator. + private final LinkedHashMap schemaByName = new LinkedHashMap(); + /// Shape-hash -> canonical SchemaInfo so identical shapes collapse. + private final LinkedHashMap shapeIndex = new LinkedHashMap(); + /// Schema name -> canonical schema name (post-unification). + private final Map nameAliases = new LinkedHashMap(); + + Generator(Map spec, String basePackage, File outputDir, boolean overwrite, + boolean emitRecords, org.apache.maven.plugin.logging.Log log) { + this.spec = spec; + this.basePackage = basePackage; + this.modelPackage = basePackage + ".model"; + this.outputDir = outputDir; + this.overwrite = overwrite; + this.emitRecords = emitRecords; + this.log = log; + Object components = spec.get("components"); + Object schemasObj = components instanceof Map ? ((Map) components).get("schemas") : null; + @SuppressWarnings("unchecked") + Map s = schemasObj instanceof Map ? (Map) schemasObj + : Collections.emptyMap(); + this.schemas = s; + } + + void run() throws IOException { + // Build per-schema info up front so the unification map is ready + // before any operation references a schema by name. + for (Map.Entry e : schemas.entrySet()) { + if (!(e.getValue() instanceof Map)) continue; + @SuppressWarnings("unchecked") + Map schema = (Map) e.getValue(); + SchemaInfo info = buildSchemaInfo(e.getKey(), schema); + if (info == null) continue; + schemaByName.put(e.getKey(), info); + } + unifyShapes(); + + // Build operations now that schema names are stable. + Object pathsObj = spec.get("paths"); + if (pathsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map paths = (Map) pathsObj; + for (Map.Entry e : paths.entrySet()) { + String path = e.getKey(); + if (!(e.getValue() instanceof Map)) continue; + @SuppressWarnings("unchecked") + Map pathItem = (Map) e.getValue(); + for (String verb : new String[]{"get", "post", "put", "delete", "patch"}) { + Object opObj = pathItem.get(verb); + if (!(opObj instanceof Map)) continue; + @SuppressWarnings("unchecked") + Map op = (Map) opObj; + OperationInfo info = buildOperation(verb, path, op, pathItem); + String tag = primaryTag(op); + List list = opsByTag.get(tag); + if (list == null) { + list = new ArrayList(); + opsByTag.put(tag, list); + } + list.add(info); + } + } + } else { + log.warn("OpenAPI spec has no `paths` -- no @RestClient interfaces will be generated."); + } + + // Emit models. + File modelDir = new File(outputDir, modelPackage.replace('.', '/')); + ensureDir(modelDir); + // Iterate canonical schemas only (unification dropped duplicates). + Set emittedCanonical = new HashSet(); + for (SchemaInfo info : schemaByName.values()) { + if (!info.isCanonical) continue; + if (!emittedCanonical.add(info.javaName)) continue; + emitModel(modelDir, info); + } + + // Emit @RestClient interfaces -- one per tag. + File apiDir = new File(outputDir, basePackage.replace('.', '/')); + ensureDir(apiDir); + for (Map.Entry> e : opsByTag.entrySet()) { + emitApi(apiDir, e.getKey(), e.getValue()); + } + + log.info("Generated " + emittedCanonical.size() + " model(s) and " + + opsByTag.size() + " @RestClient interface(s) under " + outputDir); + } + + // ---------------------------------------------------------------- + // Schema -> SchemaInfo + // ---------------------------------------------------------------- + + private SchemaInfo buildSchemaInfo(String name, Map schema) { + SchemaInfo s = new SchemaInfo(); + s.specName = name; + s.javaName = sanitizeClassName(name); + s.isCanonical = true; + Object propsObj = schema.get("properties"); + if (propsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map props = (Map) propsObj; + for (Map.Entry e : props.entrySet()) { + PropInfo p = new PropInfo(); + p.specName = e.getKey(); + p.javaName = sanitizeIdentifier(e.getKey()); + p.javaType = schemaToJavaType(e.getValue()); + s.props.add(p); + } + } + return s; + } + + /// Compute a shape hash per schema -- two schemas with the same + /// `(propName, javaType)` list in the same order collapse to a single + /// emitted record/class. We keep the first-encountered name and alias + /// the duplicates to it. + private void unifyShapes() { + for (Map.Entry e : schemaByName.entrySet()) { + SchemaInfo info = e.getValue(); + String shape = shapeOf(info); + SchemaInfo prior = shapeIndex.get(shape); + if (prior == null) { + shapeIndex.put(shape, info); + nameAliases.put(info.specName, info.javaName); + } else { + info.isCanonical = false; + info.javaName = prior.javaName; + nameAliases.put(info.specName, prior.javaName); + } + } + } + + private static String shapeOf(SchemaInfo s) { + StringBuilder sb = new StringBuilder(); + for (PropInfo p : s.props) { + sb.append(p.specName).append(':').append(p.javaType).append(';'); + } + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Operation parsing + // ---------------------------------------------------------------- + + private OperationInfo buildOperation(String verb, String path, + Map op, Map pathItem) { + String operationId = (String) op.get("operationId"); + if (operationId == null) operationId = synthesizeOperationId(verb, path); + OperationInfo info = new OperationInfo(); + info.verb = verb; + info.path = path; + info.methodName = sanitizeIdentifier(operationId); + info.summary = (String) op.get("summary"); + + // Parameters: combine path-level + operation-level. + List combined = new ArrayList(); + Object pp = pathItem.get("parameters"); + if (pp instanceof List) combined.addAll((List) pp); + Object op2 = op.get("parameters"); + if (op2 instanceof List) combined.addAll((List) op2); + for (Object pObj : combined) { + if (!(pObj instanceof Map)) continue; + @SuppressWarnings("unchecked") + Map p = (Map) pObj; + Object resolved = resolveRef(p); + @SuppressWarnings("unchecked") + Map pr = (Map) resolved; + String in = (String) pr.get("in"); + String pname = (String) pr.get("name"); + if (pname == null) continue; + ParamInfo pi = new ParamInfo(); + pi.specName = pname; + pi.javaName = sanitizeIdentifier(pname); + pi.javaType = paramTypeJava(pr); + if ("path".equals(in)) { + info.pathParams.add(pi); + } else if ("query".equals(in)) { + info.queryParams.add(pi); + } else if ("header".equals(in)) { + if (!"Authorization".equalsIgnoreCase(pname)) { + info.headerParams.add(pi); + } + // The Authorization header is exposed uniformly via the + // trailing `bearerToken` argument -- skip the duplicate. + } else if ("cookie".equals(in)) { + info.cookieParams.add(pi); + } + } + + // Request body (application/json only). + Object rb = op.get("requestBody"); + if (rb instanceof Map) { + @SuppressWarnings("unchecked") + Map body = (Map) rb; + Object content = body.get("content"); + if (content instanceof Map) { + @SuppressWarnings("unchecked") + Map cmap = (Map) content; + Object jsonEntry = cmap.get("application/json"); + if (jsonEntry instanceof Map) { + @SuppressWarnings("unchecked") + Map je = (Map) jsonEntry; + Object schema = je.get("schema"); + info.hasBody = true; + info.bodyJavaType = boxIfPrimitive(schemaToJavaType(schema)); + } + } + } + + // Response: pick the first 2xx response with application/json. + Object responses = op.get("responses"); + info.responseJavaType = "String"; // default -- treat as raw String body. + info.responseIsString = true; + if (responses instanceof Map) { + @SuppressWarnings("unchecked") + Map r = (Map) responses; + for (Map.Entry re : r.entrySet()) { + String code = re.getKey(); + if (code != null && code.startsWith("2") && re.getValue() instanceof Map) { + @SuppressWarnings("unchecked") + Map resp = (Map) re.getValue(); + Object content = resp.get("content"); + if (content instanceof Map) { + @SuppressWarnings("unchecked") + Map cmap = (Map) content; + Object jsonEntry = cmap.get("application/json"); + if (jsonEntry instanceof Map) { + @SuppressWarnings("unchecked") + Map je = (Map) jsonEntry; + Object schema = je.get("schema"); + if (schema instanceof Map) { + @SuppressWarnings("unchecked") + Map sm = (Map) schema; + if ("array".equals(sm.get("type"))) { + String elem = schemaToJavaType(sm.get("items")); + info.responseJavaType = "java.util.List<" + boxIfPrimitive(elem) + ">"; + } else { + info.responseJavaType = schemaToJavaType(schema); + } + info.responseIsString = "String".equals(info.responseJavaType); + break; + } + } + } + } + } + } + return info; + } + + private Object resolveRef(Map maybeRef) { + Object ref = maybeRef.get("$ref"); + if (!(ref instanceof String)) return maybeRef; + String r = (String) ref; + if (!r.startsWith("#/")) return maybeRef; + String[] parts = r.substring(2).split("/"); + Object cur = spec; + for (String p : parts) { + if (cur instanceof Map) cur = ((Map) cur).get(p); + else return maybeRef; + } + return cur instanceof Map ? cur : maybeRef; + } + + private String paramTypeJava(Map param) { + Object schema = param.get("schema"); + if (schema instanceof Map) return schemaToJavaType(schema); + return "String"; + } + + /// Maps an OpenAPI schema node to a Java type string. Object schemas + /// without a `$ref` collapse to `java.util.Map`. + @SuppressWarnings("unchecked") + String schemaToJavaType(Object schemaObj) { + if (!(schemaObj instanceof Map)) return "Object"; + Map schema = (Map) schemaObj; + Object ref = schema.get("$ref"); + if (ref instanceof String) { + String r = (String) ref; + int slash = r.lastIndexOf('/'); + if (slash >= 0 && r.startsWith("#/components/schemas/")) { + String specName = r.substring(slash + 1); + String alias = nameAliases.get(specName); + String name = alias != null ? alias : sanitizeClassName(specName); + return modelPackage + "." + name; + } + return "Object"; + } + Object type = schema.get("type"); + if (type instanceof String) { + String t = (String) type; + if ("integer".equals(t)) { + Object fmt = schema.get("format"); + if ("int64".equals(fmt)) return "Long"; + return "Integer"; + } + if ("number".equals(t)) { + Object fmt = schema.get("format"); + if ("float".equals(fmt)) return "Float"; + return "Double"; + } + if ("boolean".equals(t)) return "Boolean"; + if ("string".equals(t)) return "String"; + if ("array".equals(t)) { + String element = schemaToJavaType(schema.get("items")); + return "java.util.List<" + boxIfPrimitive(element) + ">"; + } + } + if (schema.containsKey("allOf") || schema.containsKey("oneOf") || schema.containsKey("anyOf")) { + return "Object"; + } + return "java.util.Map"; + } + + private String primaryTag(Map op) { + Object tags = op.get("tags"); + if (tags instanceof List && !((List) tags).isEmpty()) { + Object first = ((List) tags).get(0); + if (first instanceof String) return sanitizeClassName((String) first); + } + return "Default"; + } + + // ---------------------------------------------------------------- + // Source emit + // ---------------------------------------------------------------- + + private void emitModel(File dir, SchemaInfo info) throws IOException { + File f = new File(dir, info.javaName + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(1024); + sb.append("// Generated by cn1:generate-openapi.\n"); + sb.append("package ").append(modelPackage).append(";\n\n"); + sb.append("import com.codename1.annotations.JsonProperty;\n"); + sb.append("import com.codename1.annotations.Mapped;\n\n"); + if (emitRecords) { + sb.append("@Mapped\n"); + sb.append("public record ").append(info.javaName).append("("); + for (int i = 0; i < info.props.size(); i++) { + PropInfo p = info.props.get(i); + if (i > 0) sb.append(", "); + sb.append("@JsonProperty(\"").append(escapeJava(p.specName)).append("\") ") + .append(boxIfPrimitive(p.javaType)).append(' ').append(p.javaName); + } + sb.append(") {}\n"); + } else { + sb.append("@Mapped\n"); + sb.append("public class ").append(info.javaName).append(" {\n"); + for (PropInfo p : info.props) { + sb.append(" @JsonProperty(\"").append(escapeJava(p.specName)).append("\")\n"); + sb.append(" public ").append(p.javaType).append(' ') + .append(p.javaName).append(";\n"); + } + sb.append(" public ").append(info.javaName).append("() {}\n"); + sb.append("}\n"); + } + writeFile(f, sb.toString()); + } + + private void emitApi(File dir, String tag, List ops) throws IOException { + String className = tag.endsWith("Api") ? tag : tag + "Api"; + File f = new File(dir, className + ".java"); + if (f.exists() && !overwrite) { + log.debug("skip existing " + f); + return; + } + StringBuilder sb = new StringBuilder(2048); + sb.append("// Generated by cn1:generate-openapi.\n"); + sb.append("package ").append(basePackage).append(";\n\n"); + sb.append("import com.codename1.annotations.rest.Body;\n"); + sb.append("import com.codename1.annotations.rest.Cookie;\n"); + sb.append("import com.codename1.annotations.rest.DELETE;\n"); + sb.append("import com.codename1.annotations.rest.GET;\n"); + sb.append("import com.codename1.annotations.rest.Header;\n"); + sb.append("import com.codename1.annotations.rest.PATCH;\n"); + sb.append("import com.codename1.annotations.rest.POST;\n"); + sb.append("import com.codename1.annotations.rest.PUT;\n"); + sb.append("import com.codename1.annotations.rest.Path;\n"); + sb.append("import com.codename1.annotations.rest.Query;\n"); + sb.append("import com.codename1.annotations.rest.RestClient;\n"); + sb.append("import com.codename1.io.rest.Response;\n"); + sb.append("import com.codename1.io.rest.RestClients;\n"); + sb.append("import com.codename1.util.OnComplete;\n\n"); + sb.append("@RestClient\n"); + sb.append("public interface ").append(className).append(" {\n\n"); + Set usedNames = new LinkedHashSet(); + for (OperationInfo op : ops) { + emitOperationMethod(sb, op, usedNames); + } + sb.append(" static ").append(className).append(" of(String baseUrl) {\n"); + sb.append(" return RestClients.create(").append(className).append(".class, baseUrl);\n"); + sb.append(" }\n"); + sb.append("}\n"); + writeFile(f, sb.toString()); + } + + private void emitOperationMethod(StringBuilder sb, OperationInfo op, Set usedNames) { + if (op.summary != null && op.summary.length() > 0) { + sb.append(" /// ").append(escapeAdoc(op.summary)).append("\n"); + } + // Verb annotation. + sb.append(" @").append(verbAnnotation(op.verb)).append("(\"") + .append(escapeJava(op.path)).append("\")\n"); + String methodName = uniqueName(op.methodName, usedNames); + sb.append(" void ").append(methodName).append("("); + boolean first = true; + for (ParamInfo p : op.pathParams) { + if (!first) sb.append(", "); + sb.append("@Path(\"").append(escapeJava(p.specName)).append("\") ") + .append(boxIfPrimitive(p.javaType)).append(' ').append(p.javaName); + first = false; + } + for (ParamInfo p : op.queryParams) { + if (!first) sb.append(", "); + sb.append("@Query(\"").append(escapeJava(p.specName)).append("\") ") + .append(boxIfPrimitive(p.javaType)).append(' ').append(p.javaName); + first = false; + } + for (ParamInfo p : op.headerParams) { + if (!first) sb.append(", "); + sb.append("@Header(\"").append(escapeJava(p.specName)).append("\") ") + .append(boxIfPrimitive(p.javaType)).append(' ').append(p.javaName); + first = false; + } + for (ParamInfo p : op.cookieParams) { + if (!first) sb.append(", "); + sb.append("@Cookie(\"").append(escapeJava(p.specName)).append("\") ") + .append(boxIfPrimitive(p.javaType)).append(' ').append(p.javaName); + first = false; + } + if (op.hasBody) { + if (!first) sb.append(", "); + sb.append("@Body ").append(op.bodyJavaType).append(" body"); + first = false; + } + // Bearer token last before callback. + if (!first) sb.append(", "); + sb.append("@Header(\"Authorization\") String bearerToken"); + first = false; + sb.append(", OnComplete> callback);\n\n"); + } + + private static String uniqueName(String base, Set used) { + if (used.add(base)) return base; + int n = 2; + while (!used.add(base + n)) n++; + return base + n; + } + + private static String verbAnnotation(String verb) { + return verb.toUpperCase(Locale.ROOT); + } + + // ---------------------------------------------------------------- + // Helpers shared with the old codegen + // ---------------------------------------------------------------- + + private static String boxIfPrimitive(String type) { + if (type == null) return "Object"; + if (type.equals("int")) return "Integer"; + if (type.equals("long")) return "Long"; + if (type.equals("double")) return "Double"; + if (type.equals("float")) return "Float"; + if (type.equals("boolean")) return "Boolean"; + if (type.equals("byte")) return "Byte"; + if (type.equals("short")) return "Short"; + if (type.equals("char")) return "Character"; + return type; + } + + private static String escapeJava(String s) { + if (s == null) return ""; + StringBuilder sb = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') sb.append('\\'); + sb.append(c); + } + return sb.toString(); + } + + private static String escapeAdoc(String s) { + return s.replace("\n", " ").replace("\r", " "); + } + + private static void ensureDir(File f) throws IOException { + if (!f.exists() && !f.mkdirs()) { + throw new IOException("Could not create " + f); + } + } + + private static void writeFile(File f, String content) throws IOException { + FileOutputStream out = new FileOutputStream(f); + try { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } finally { + out.close(); + } + } + } + + // ---------------------------------------------------------------- + // Static helpers + // ---------------------------------------------------------------- + + /// Strips characters that would make a name invalid as a Java identifier, + /// upper-cases the first letter to match Java class-name convention. + static String sanitizeClassName(String s) { + if (s == null) return "Anonymous"; + StringBuilder sb = new StringBuilder(); + boolean upper = true; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '_' || c == '-' || c == ' ' || c == '.') { upper = true; continue; } + if (!Character.isJavaIdentifierPart(c)) continue; + if (sb.length() == 0 && !Character.isJavaIdentifierStart(c)) sb.append('_'); + sb.append(upper ? Character.toUpperCase(c) : c); + upper = false; + } + if (sb.length() == 0) return "Anonymous"; + return sb.toString(); + } + + static String sanitizeIdentifier(String s) { + if (s == null) return "anonymous"; + StringBuilder sb = new StringBuilder(); + boolean upperNext = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '_' || c == '-' || c == ' ' || c == '.') { upperNext = true; continue; } + if (!Character.isJavaIdentifierPart(c)) continue; + if (sb.length() == 0 && !Character.isJavaIdentifierStart(c)) sb.append('_'); + sb.append(upperNext ? Character.toUpperCase(c) : c); + upperNext = false; + } + if (sb.length() == 0) return "anonymous"; + sb.setCharAt(0, Character.toLowerCase(sb.charAt(0))); + String word = sb.toString(); + if (isJavaReservedWord(word)) return word + "_"; + return word; + } + + private static boolean isJavaReservedWord(String s) { + return s.equals("abstract") || s.equals("assert") || s.equals("boolean") || s.equals("break") + || s.equals("byte") || s.equals("case") || s.equals("catch") || s.equals("char") + || s.equals("class") || s.equals("const") || s.equals("continue") || s.equals("default") + || s.equals("do") || s.equals("double") || s.equals("else") || s.equals("enum") + || s.equals("extends") || s.equals("final") || s.equals("finally") || s.equals("float") + || s.equals("for") || s.equals("goto") || s.equals("if") || s.equals("implements") + || s.equals("import") || s.equals("instanceof") || s.equals("int") || s.equals("interface") + || s.equals("long") || s.equals("native") || s.equals("new") || s.equals("null") + || s.equals("package") || s.equals("private") || s.equals("protected") || s.equals("public") + || s.equals("return") || s.equals("short") || s.equals("static") || s.equals("strictfp") + || s.equals("super") || s.equals("switch") || s.equals("synchronized") || s.equals("this") + || s.equals("throw") || s.equals("throws") || s.equals("transient") || s.equals("true") + || s.equals("false") || s.equals("try") || s.equals("void") || s.equals("volatile") + || s.equals("while") || s.equals("record"); + } + + private static String synthesizeOperationId(String verb, String path) { + StringBuilder sb = new StringBuilder(verb); + boolean upper = true; + for (int i = 0; i < path.length(); i++) { + char c = path.charAt(i); + if (c == '/' || c == '{' || c == '}' || c == '-' || c == '_') { upper = true; continue; } + if (!Character.isJavaIdentifierPart(c)) continue; + sb.append(upper ? Character.toUpperCase(c) : c); + upper = false; + } + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Accumulators + // ---------------------------------------------------------------- + + static final class SchemaInfo { + String specName; // original OpenAPI name + String javaName; // sanitized Java identifier (post-unification) + boolean isCanonical; // false when this entry is aliased to another + final List props = new ArrayList(); + } + + static final class PropInfo { + String specName; + String javaName; + String javaType; + } + + static final class OperationInfo { + String verb; + String path; + String methodName; + String summary; + final List pathParams = new ArrayList(); + final List queryParams = new ArrayList(); + final List headerParams = new ArrayList(); + final List cookieParams = new ArrayList(); + boolean hasBody; + String bodyJavaType; + String responseJavaType; + boolean responseIsString; + } + + static final class ParamInfo { + String specName; + String javaName; + String javaType; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java index 5b19e1101b..d137d959d4 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/StubGenerator.java @@ -697,7 +697,22 @@ private boolean isValidType(Class cls) { return true; } if(cls.isArray()) { - return cls.getComponentType().isPrimitive(); + // Primitive arrays (byte[], int[], long[], double[], float[], + // boolean[], char[], short[]) plus String[] are valid parameter + // and return types. + // + // CAVEAT: the iOS Objective-C marshaller at + // javaTypeToObjectiveCType() currently maps every array to + // NSData* -- i.e. iOS callers receive byte-buffer semantics + // regardless of declared component type. For int[] / long[] / + // double[] / float[] this means the native side has to read the + // raw bytes (still useful for fixed-format payloads). For + // String[] this means the native side has to deserialize the + // payload itself. Per-component-type ObjC marshalling + // (NSArray* / NSArray*) is a separate + // follow-up; see the IMPROVEMENT_PLAN. + Class component = cls.getComponentType(); + return component.isPrimitive() || component == String.class; } if(cls == String.class) { return true; 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 index 7bdcc253c3..c3503759da 100644 --- 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 @@ -90,6 +90,13 @@ public final class AnnotatedClass { public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } public boolean isSynthetic() { return (access & Opcodes.ACC_SYNTHETIC) != 0; } + /// `true` when the class file's `ACC_RECORD` flag is set (Java 16+ record). + /// Inlined as a constant so this code keeps compiling against ASM versions + /// that predate `Opcodes.ACC_RECORD`. + public boolean isRecord() { return (access & ACC_RECORD) != 0; } + + private static final int ACC_RECORD = 0x10000; + /// Class-level annotations, keyed by JVM descriptor. public Map getClassAnnotations() { return annotations; } 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 index 394a1dac14..427b71fc4a 100644 --- 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 @@ -177,8 +177,13 @@ public MethodVisitor visitMethod(int access, String name, String descriptor, final int mAccess = access; final String mName = name; final String mDesc = descriptor; + final String mSig = signature; final Map mAnnotations = new LinkedHashMap(); + // Parameter-annotation maps are created lazily on first write so + // unannotated methods keep an empty list rather than a sparse one. + final List> paramAnnotations = + new ArrayList>(); return new MethodVisitor(API) { @Override public AnnotationVisitor visitAnnotation(String d, boolean v) { @@ -187,9 +192,22 @@ public AnnotationVisitor visitAnnotation(String d, boolean v) { return new AnnotationCollector(API, values); } + @Override + public AnnotationVisitor visitParameterAnnotation(int parameter, + String d, boolean v) { + while (paramAnnotations.size() <= parameter) { + paramAnnotations.add(new LinkedHashMap()); + } + Map values = new LinkedHashMap(); + paramAnnotations.get(parameter) + .put(d, new AnnotationValues(d, values)); + return new AnnotationCollector(API, values); + } + @Override public void visitEnd() { - methods.add(new MethodInfo(mName, mDesc, mAccess, mAnnotations)); + methods.add(new MethodInfo(mName, mDesc, mSig, mAccess, + mAnnotations, paramAnnotations)); } }; } 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 index 395dfe22f9..0a4364a066 100644 --- 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 @@ -22,8 +22,10 @@ */ package com.codename1.maven.annotations; +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; @@ -38,20 +40,46 @@ public final class MethodInfo { private final String name; private final String descriptor; + private final String signature; private final int access; private final Map annotations; + private final List> parameterAnnotations; - MethodInfo(String name, String descriptor, int access, Map annotations) { + MethodInfo(String name, String descriptor, String signature, int access, + Map annotations, + List> parameterAnnotations) { this.name = name; this.descriptor = descriptor; + this.signature = signature; this.access = access; this.annotations = (annotations == null) ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap(annotations)); + if (parameterAnnotations == null) { + this.parameterAnnotations = Collections.emptyList(); + } else { + List> copy = + new ArrayList>(parameterAnnotations.size()); + for (Map m : parameterAnnotations) { + copy.add(m == null + ? Collections.emptyMap() + : Collections.unmodifiableMap( + new LinkedHashMap(m))); + } + this.parameterAnnotations = Collections.unmodifiableList(copy); + } } public String getName() { return name; } public String getDescriptor() { return descriptor; } + + /// The JVM generic-type signature (e.g. + /// `(Ljava/lang/Long;Lcom/codename1/util/OnComplete<...>;)V`). Null when + /// the class file does not carry a signature attribute for this method + /// (no generics, no type parameters). Processors that need to inspect + /// type arguments on parameters parse this string. + public String getSignature() { return signature; } + public int getAccess() { return access; } public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } @@ -69,6 +97,15 @@ public final class MethodInfo { /// Convenience: look up an annotation by descriptor, returning null if absent. public AnnotationValues getAnnotation(String descriptor) { return annotations.get(descriptor); } + /// Annotations attached to each parameter, indexed by parameter position + /// (0-based). Each entry is keyed by annotation descriptor. The list size + /// equals the parameter count when the class file records any parameter + /// annotations, and is empty otherwise -- callers should treat an empty + /// list as "no parameter carries any annotation". + public List> getParameterAnnotations() { + return parameterAnnotations; + } + @Override public String toString() { return name + descriptor; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java index 3e056c4afa..df0030a852 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -98,12 +98,13 @@ public void start(ProcessorContext ctx) throws ProcessingException { public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { if (cls.isSynthetic()) return; if (cls.getClassAnnotation(MAPPED_DESC) == null) return; - if (cls.isAbstract() || cls.isInterface()) { + boolean isRecord = cls.isRecord(); + if (!isRecord && (cls.isAbstract() || cls.isInterface())) { ctx.error(cls, "@Mapped requires a concrete class; " + cls.getBinaryName() + " is abstract or an interface"); return; } - if (!hasPublicNoArgConstructor(cls)) { + if (!isRecord && !hasPublicNoArgConstructor(cls)) { ctx.error(cls, "@Mapped class " + cls.getBinaryName() + " must declare a public no-arg constructor for fromJson / fromXml"); return; @@ -117,6 +118,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces mc.mapperBinaryName = (mc.packageName.length() == 0) ? mc.mapperSimpleName : mc.packageName + "." + mc.mapperSimpleName; + mc.isRecord = isRecord; AnnotationValues xmlRoot = cls.getClassAnnotation(XML_ROOT_DESC); if (xmlRoot != null) { @@ -129,13 +131,53 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces for (FieldInfo f : cls.getFields()) { if (f.isStatic()) continue; if (f.getName().startsWith("this$")) continue; // inner-class outer ref - if (!f.isPublic()) { - // Skip silently. JavaBeans-style accessors are a v2 enhancement. - continue; + boolean useAccessor = false; + String getterName = null; + String setterName = null; + if (!isRecord && !f.isPublic()) { + // POJO with non-public field: try the JavaBeans path. A + // matching `getX()` (or `isX()` for booleans) plus a + // `setX(FieldType)` on the same class promotes the field + // to a first-class @Mapped target. Property / ListProperty + // fields only need the getter -- the field itself is not + // replaced, only its inner value mutated through .set(...) / + // .add(...). + PropertyTypeKind probeKind = PropertyTypeKind.of(f); + String getter = findGetter(cls, f, probeKind); + if (getter == null) { + ctx.getLog().debug("cn1: @Mapped skipping private field " + + f.getName() + " on " + cls.getBinaryName() + + " (no matching getter/setter)"); + continue; + } + boolean needsSetter = !(probeKind.kind == PropertyTypeKind.Kind.PROPERTY + || probeKind.kind == PropertyTypeKind.Kind.LIST_PROPERTY); + String setter = needsSetter ? findSetter(cls, f) : null; + if (needsSetter && setter == null) { + ctx.getLog().debug("cn1: @Mapped skipping private field " + + f.getName() + " on " + cls.getBinaryName() + + " (no matching getter/setter)"); + continue; + } + useAccessor = true; + getterName = getter; + setterName = setter; } MappedField mf = new MappedField(); mf.name = f.getName(); mf.kind = PropertyTypeKind.of(f); + mf.useAccessor = useAccessor; + mf.getterName = getterName; + mf.setterName = setterName; + if (isRecord) { + if (mf.kind.kind == PropertyTypeKind.Kind.PROPERTY + || mf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { + ctx.error(cls, "@Mapped record " + mc.binaryName + + " cannot use Property / ListProperty for component " + + mf.name + " -- records are immutable"); + continue; + } + } mf.jsonName = mf.name; mf.xmlName = mf.name; mf.xmlAttribute = false; @@ -210,6 +252,72 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // Source generation // --------------------------------------------------------------- + /// Returns the Java expression that reads the field's current value + /// from the runtime instance `o`. Records use the synthesised accessor + /// (`o.name()`); JavaBeans-style POJOs use their public getter + /// (`o.getName()` / `o.isFlag()`); plain POJOs use direct field access + /// (`o.name`). + private static String readExpr(MappedField f, boolean isRecord) { + if (isRecord) return "o." + f.name + "()"; + if (f.useAccessor) return "o." + f.getterName + "()"; + return "o." + f.name; + } + + /// Returns a full Java statement (terminated with `;`) that writes + /// `rhsExpr` into the field for the runtime instance `o`. Records + /// can't mutate after construction, so we accumulate in a local of + /// the form `_name` for later canonical-constructor invocation; + /// JavaBeans POJOs route through `o.setName(rhsExpr)`; plain POJOs + /// assign the field directly. + private static String writeStmt(MappedField f, boolean isRecord, String rhsExpr) { + if (isRecord) return "_" + f.name + " = " + rhsExpr + ";"; + if (f.useAccessor) return "o." + f.setterName + "(" + rhsExpr + ");"; + return "o." + f.name + " = " + rhsExpr + ";"; + } + + /// The literal Java initializer for a freshly-declared local of the + /// given kind -- used when seeding the per-component locals that + /// feed a record's canonical constructor. + private static String defaultLiteral(PropertyTypeKind k) { + switch (k.kind) { + case INT: case SHORT: case BYTE: case CHAR: return "0"; + case LONG: return "0L"; + case DOUBLE: return "0.0"; + case FLOAT: return "0.0f"; + case BOOLEAN: return "false"; + default: return "null"; + } + } + + /// The Java type literal used to declare a local variable carrying + /// the field's value (records-only: the locals are later fed to the + /// canonical constructor in declaration order). + private static String fieldType(MappedField f) { + switch (f.kind.kind) { + case STRING: return "String"; + case INT: return "int"; + case LONG: return "long"; + case SHORT: return "short"; + case BYTE: return "byte"; + case CHAR: return "char"; + case DOUBLE: return "double"; + case FLOAT: return "float"; + case BOOLEAN: return "boolean"; + case DATE: return "java.util.Date"; + case BYTE_ARRAY: return "byte[]"; + // PROPERTY / LIST_PROPERTY are rejected upstream for records; + // they share the same fallback shape as a plain REFERENCE so the + // helper can't NPE if it's ever reached from a non-record path. + case REFERENCE: + case PROPERTY: + case LIST_PROPERTY: + return f.kind.binaryName; + case LIST: return "java.util.List<" + f.kind.elementBinaryName + ">"; + default: + return "Object"; + } + } + private static String generateMapperSource(MappedClass mc) { StringBuilder sb = new StringBuilder(2048); if (mc.packageName.length() > 0) { @@ -241,27 +349,48 @@ private static String generateMapperSource(MappedClass mc) { sb.append(" return \"").append(escape(mc.xmlRootName)).append("\";\n"); sb.append(" }\n\n"); - // toMap() + boolean isRecord = mc.isRecord; + + // toMap() -- reads are identical in shape regardless of record-ness; + // `readExpr` picks between `o.name` (POJO) and `o.name()` (record). sb.append(" public java.util.Map toMap(").append(mc.binaryName).append(" o) {\n"); sb.append(" java.util.LinkedHashMap m = new java.util.LinkedHashMap();\n"); sb.append(" if (o == null) return m;\n"); for (MappedField f : mc.fields) { if (!f.includeInJson) continue; - emitFieldToMap(sb, f); + emitFieldToMap(sb, f, isRecord); } sb.append(" return m;\n"); sb.append(" }\n\n"); - // fromMap() + // fromMap() -- POJO mutates an instance in-place; record accumulates + // per-component locals and feeds them to the canonical constructor. sb.append(" public ").append(mc.binaryName) .append(" fromMap(java.util.Map m) {\n"); - sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); - sb.append(" if (m == null) return o;\n"); - for (MappedField f : mc.fields) { - if (!f.includeInJson) continue; - emitFieldFromMap(sb, f); + if (isRecord) { + for (MappedField f : mc.fields) { + sb.append(" ").append(fieldType(f)).append(" _") + .append(f.name).append(" = ").append(defaultLiteral(f.kind)).append(";\n"); + } + sb.append(" if (m == null) return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + emitFieldFromMap(sb, f, true); + } + sb.append(" return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + } else { + sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); + sb.append(" if (m == null) return o;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + emitFieldFromMap(sb, f, false); + } + sb.append(" return o;\n"); } - sb.append(" return o;\n"); sb.append(" }\n\n"); // writeXml() @@ -270,20 +399,37 @@ private static String generateMapperSource(MappedClass mc) { sb.append(" if (o == null) return;\n"); for (MappedField f : mc.fields) { if (!f.includeInXml) continue; - emitFieldToXml(sb, f); + emitFieldToXml(sb, f, isRecord); } sb.append(" }\n\n"); - // readXml() + // readXml() -- same fork as fromMap. sb.append(" public ").append(mc.binaryName) .append(" readXml(com.codename1.xml.Element root) {\n"); - sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); - sb.append(" if (root == null) return o;\n"); - for (MappedField f : mc.fields) { - if (!f.includeInXml) continue; - emitFieldFromXml(sb, f); + if (isRecord) { + for (MappedField f : mc.fields) { + sb.append(" ").append(fieldType(f)).append(" _") + .append(f.name).append(" = ").append(defaultLiteral(f.kind)).append(";\n"); + } + sb.append(" if (root == null) return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + for (MappedField f : mc.fields) { + if (!f.includeInXml) continue; + emitFieldFromXml(sb, f, true); + } + sb.append(" return "); + appendRecordCtorCall(sb, mc); + sb.append(";\n"); + } else { + sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); + sb.append(" if (root == null) return o;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInXml) continue; + emitFieldFromXml(sb, f, false); + } + sb.append(" return o;\n"); } - sb.append(" return o;\n"); sb.append(" }\n\n"); // textOf helper -- inlined per-mapper to keep each generated class @@ -303,6 +449,22 @@ private static String generateMapperSource(MappedClass mc) { return sb.toString(); } + /// Appends `new pkg.Type(_a, _b, ...)` to `sb`, where the args are the + /// per-component locals declared at the top of a record `fromMap` / + /// `readXml`. Order tracks `mc.fields` which is the class-file + /// declaration order, identical to the canonical constructor's + /// parameter order. + private static void appendRecordCtorCall(StringBuilder sb, MappedClass mc) { + sb.append("new ").append(mc.binaryName).append("("); + boolean first = true; + for (MappedField f : mc.fields) { + if (!first) sb.append(", "); + sb.append("_").append(f.name); + first = false; + } + sb.append(")"); + } + private static String generateBootstrapSource(Iterable classes) { StringBuilder sb = new StringBuilder(1024); sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); @@ -333,31 +495,32 @@ private static String packageOf(String binary) { // toMap field-emit helpers // --------------------------------------------------------------- - private static void emitFieldToMap(StringBuilder sb, MappedField f) { + private static void emitFieldToMap(StringBuilder sb, MappedField f, boolean isRecord) { String key = "\"" + escape(f.jsonName) + "\""; + String read = readExpr(f, isRecord); switch (f.kind.kind) { case STRING: case INT: case LONG: case SHORT: case BYTE: case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: - sb.append(" m.put(").append(key).append(", o.").append(f.name).append(");\n"); + sb.append(" m.put(").append(key).append(", ").append(read).append(");\n"); return; case DATE: - sb.append(" m.put(").append(key).append(", o.").append(f.name) - .append(" == null ? null : Long.valueOf(o.").append(f.name).append(".getTime()));\n"); + sb.append(" m.put(").append(key).append(", ").append(read) + .append(" == null ? null : Long.valueOf(").append(read).append(".getTime()));\n"); return; case BYTE_ARRAY: - sb.append(" m.put(").append(key).append(", o.").append(f.name) - .append(" == null ? null : com.codename1.util.Base64.encode(o.").append(f.name).append("));\n"); + sb.append(" m.put(").append(key).append(", ").append(read) + .append(" == null ? null : com.codename1.util.Base64.encode(").append(read).append("));\n"); return; case PROPERTY: - sb.append(" m.put(").append(key).append(", o.").append(f.name).append(".get());\n"); + sb.append(" m.put(").append(key).append(", ").append(read).append(".get());\n"); return; case LIST: case LIST_PROPERTY: sb.append(" {\n"); sb.append(" java.util.ArrayList _l = new java.util.ArrayList();\n"); if (f.kind.kind == PropertyTypeKind.Kind.LIST) { - sb.append(" java.util.List _src = o.").append(f.name).append(";\n"); + sb.append(" java.util.List _src = ").append(read).append(";\n"); } else { - sb.append(" java.util.List _src = o.").append(f.name).append(".asList();\n"); + sb.append(" java.util.List _src = ").append(read).append(".asList();\n"); } sb.append(" if (_src != null) {\n"); sb.append(" for (Object _e : _src) {\n"); @@ -376,7 +539,7 @@ private static void emitFieldToMap(StringBuilder sb, MappedField f) { return; case REFERENCE: sb.append(" {\n"); - sb.append(" Object _v = o.").append(f.name).append(";\n"); + sb.append(" Object _v = ").append(read).append(";\n"); sb.append(" if (_v == null) { m.put(").append(key).append(", null); }\n"); sb.append(" else {\n"); sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); @@ -393,73 +556,77 @@ private static void emitFieldToMap(StringBuilder sb, MappedField f) { // fromMap field-emit helpers // --------------------------------------------------------------- - private static void emitFieldFromMap(StringBuilder sb, MappedField f) { + private static void emitFieldFromMap(StringBuilder sb, MappedField f, boolean isRecord) { String key = "\"" + escape(f.jsonName) + "\""; sb.append(" {\n"); sb.append(" Object _v = m.get(").append(key).append(");\n"); sb.append(" if (_v != null) {\n"); switch (f.kind.kind) { case STRING: - sb.append(" o.").append(f.name).append(" = _v.toString();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_v.toString()")).append("\n"); break; case INT: - sb.append(" o.").append(f.name).append(" = ((Number) _v).intValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).intValue()")).append("\n"); break; case LONG: - sb.append(" o.").append(f.name).append(" = ((Number) _v).longValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).longValue()")).append("\n"); break; case SHORT: - sb.append(" o.").append(f.name).append(" = ((Number) _v).shortValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).shortValue()")).append("\n"); break; case BYTE: - sb.append(" o.").append(f.name).append(" = ((Number) _v).byteValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).byteValue()")).append("\n"); break; case CHAR: - sb.append(" o.").append(f.name).append(" = _v.toString().length() == 0 ? '\\0' : _v.toString().charAt(0);\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_v.toString().length() == 0 ? '\\0' : _v.toString().charAt(0)")).append("\n"); break; case DOUBLE: - sb.append(" o.").append(f.name).append(" = ((Number) _v).doubleValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).doubleValue()")).append("\n"); break; case FLOAT: - sb.append(" o.").append(f.name).append(" = ((Number) _v).floatValue();\n"); + sb.append(" ").append(writeStmt(f, isRecord, "((Number) _v).floatValue()")).append("\n"); break; case BOOLEAN: - sb.append(" o.").append(f.name).append(" = (_v instanceof Boolean) ? ((Boolean) _v).booleanValue() : Boolean.parseBoolean(_v.toString());\n"); + sb.append(" ").append(writeStmt(f, isRecord, "(_v instanceof Boolean) ? ((Boolean) _v).booleanValue() : Boolean.parseBoolean(_v.toString())")).append("\n"); break; case DATE: - sb.append(" o.").append(f.name).append(" = new java.util.Date(((Number) _v).longValue());\n"); + sb.append(" ").append(writeStmt(f, isRecord, "new java.util.Date(((Number) _v).longValue())")).append("\n"); break; case BYTE_ARRAY: - sb.append(" o.").append(f.name).append(" = com.codename1.util.Base64.decode(_v.toString().getBytes());\n"); + sb.append(" ").append(writeStmt(f, isRecord, "com.codename1.util.Base64.decode(_v.toString().getBytes())")).append("\n"); break; case PROPERTY: - emitPropertySetFromJsonValue(sb, f); + emitPropertySetFromJsonValue(sb, f, isRecord); break; case REFERENCE: sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); - sb.append(" o.").append(f.name).append(" = (").append(f.kind.binaryName).append(") _nm.fromMap((java.util.Map) _v);\n"); + sb.append(" ").append(writeStmt(f, isRecord, "(" + f.kind.binaryName + ") _nm.fromMap((java.util.Map) _v)")).append("\n"); sb.append(" }\n"); break; case LIST: case LIST_PROPERTY: + // LIST_PROPERTY mutates o.field.clear()/add(...) -- safe for + // POJO path only (records reject LIST_PROPERTY upstream). For + // a record's LIST we materialize a local _l and assign it to + // the per-component local that later feeds the canonical ctor. sb.append(" if (_v instanceof java.util.List) {\n"); if (isScalarBinary(f.kind.elementBinaryName)) { if (f.kind.kind == PropertyTypeKind.Kind.LIST) { sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); sb.append(" for (Object _e : (java.util.List) _v) { _l.add((").append(f.kind.elementBinaryName).append(") _e); }\n"); - sb.append(" o.").append(f.name).append(" = _l;\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); } else { - sb.append(" o.").append(f.name).append(".clear();\n"); - sb.append(" for (Object _e : (java.util.List) _v) { o.").append(f.name).append(".add((").append(f.kind.elementBinaryName).append(") _e); }\n"); + sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { ").append(readExpr(f, isRecord)).append(".add((").append(f.kind.elementBinaryName).append(") _e); }\n"); } } else if ("java.util.Date".equals(f.kind.elementBinaryName)) { if (f.kind.kind == PropertyTypeKind.Kind.LIST) { sb.append(" java.util.ArrayList _l = new java.util.ArrayList();\n"); sb.append(" for (Object _e : (java.util.List) _v) { _l.add(_e == null ? null : new java.util.Date(((Number) _e).longValue())); }\n"); - sb.append(" o.").append(f.name).append(" = _l;\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); } else { - sb.append(" o.").append(f.name).append(".clear();\n"); - sb.append(" for (Object _e : (java.util.List) _v) { o.").append(f.name).append(".add(_e == null ? null : new java.util.Date(((Number) _e).longValue())); }\n"); + sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { ").append(readExpr(f, isRecord)).append(".add(_e == null ? null : new java.util.Date(((Number) _e).longValue())); }\n"); } } else { sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.elementBinaryName).append(".class);\n"); @@ -468,11 +635,11 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f) { sb.append(" for (Object _e : (java.util.List) _v) {\n"); sb.append(" if (_nm != null && _e instanceof java.util.Map) { _l.add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); sb.append(" }\n"); - sb.append(" o.").append(f.name).append(" = _l;\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); } else { - sb.append(" o.").append(f.name).append(".clear();\n"); + sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); sb.append(" for (Object _e : (java.util.List) _v) {\n"); - sb.append(" if (_nm != null && _e instanceof java.util.Map) { o.").append(f.name).append(".add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); + sb.append(" if (_nm != null && _e instanceof java.util.Map) { ").append(readExpr(f, isRecord)).append(".add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); sb.append(" }\n"); } } @@ -485,27 +652,31 @@ private static void emitFieldFromMap(StringBuilder sb, MappedField f) { sb.append(" }\n"); } - private static void emitPropertySetFromJsonValue(StringBuilder sb, MappedField f) { + private static void emitPropertySetFromJsonValue(StringBuilder sb, MappedField f, boolean isRecord) { + // PROPERTY fields are rejected upstream for records; read through + // `readExpr` anyway so the helper is safe if it ever runs in the + // record path. + String prop = readExpr(f, isRecord); String elem = f.kind.elementBinaryName; if ("java.lang.String".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(_v.toString());\n"); + sb.append(" ").append(prop).append(".set(_v.toString());\n"); } else if ("java.lang.Integer".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Integer.valueOf(((Number) _v).intValue()));\n"); + sb.append(" ").append(prop).append(".set(Integer.valueOf(((Number) _v).intValue()));\n"); } else if ("java.lang.Long".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Long.valueOf(((Number) _v).longValue()));\n"); + sb.append(" ").append(prop).append(".set(Long.valueOf(((Number) _v).longValue()));\n"); } else if ("java.lang.Double".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Double.valueOf(((Number) _v).doubleValue()));\n"); + sb.append(" ").append(prop).append(".set(Double.valueOf(((Number) _v).doubleValue()));\n"); } else if ("java.lang.Float".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Float.valueOf(((Number) _v).floatValue()));\n"); + sb.append(" ").append(prop).append(".set(Float.valueOf(((Number) _v).floatValue()));\n"); } else if ("java.lang.Boolean".equals(elem)) { - sb.append(" o.").append(f.name).append(".set((_v instanceof Boolean) ? (Boolean) _v : Boolean.valueOf(_v.toString()));\n"); + sb.append(" ").append(prop).append(".set((_v instanceof Boolean) ? (Boolean) _v : Boolean.valueOf(_v.toString()));\n"); } else if ("java.util.Date".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(new java.util.Date(((Number) _v).longValue()));\n"); + sb.append(" ").append(prop).append(".set(new java.util.Date(((Number) _v).longValue()));\n"); } else { // Nested Property where T is another mapped type. sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(elem).append(".class);\n"); sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); - sb.append(" o.").append(f.name).append(".set((").append(elem).append(") _nm.fromMap((java.util.Map) _v));\n"); + sb.append(" ").append(prop).append(".set((").append(elem).append(") _nm.fromMap((java.util.Map) _v));\n"); sb.append(" }\n"); } } @@ -514,11 +685,12 @@ private static void emitPropertySetFromJsonValue(StringBuilder sb, MappedField f // XML helpers // --------------------------------------------------------------- - private static void emitFieldToXml(StringBuilder sb, MappedField f) { + private static void emitFieldToXml(StringBuilder sb, MappedField f, boolean isRecord) { + String read = readExpr(f, isRecord); if (f.xmlAttribute) { sb.append(" {\n"); sb.append(" String _s = "); - emitScalarToString(sb, f); + emitScalarToString(sb, f, isRecord); sb.append(";\n"); sb.append(" if (_s != null) root.setAttribute(\"").append(escape(f.xmlName)).append("\", _s);\n"); sb.append(" }\n"); @@ -530,7 +702,7 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f) { case PROPERTY: sb.append(" {\n"); sb.append(" String _s = "); - emitScalarToString(sb, f); + emitScalarToString(sb, f, isRecord); sb.append(";\n"); sb.append(" if (_s != null) {\n"); sb.append(" com.codename1.xml.Element _e = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); @@ -541,11 +713,11 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f) { sb.append(" }\n"); return; case REFERENCE: - sb.append(" if (o.").append(f.name).append(" != null) {\n"); + sb.append(" if (").append(read).append(" != null) {\n"); sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); sb.append(" if (_nm != null) {\n"); sb.append(" com.codename1.xml.Element _e = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); - sb.append(" _nm.writeXml(o.").append(f.name).append(", _e);\n"); + sb.append(" _nm.writeXml(").append(read).append(", _e);\n"); sb.append(" root.addChild(_e);\n"); sb.append(" }\n"); sb.append(" }\n"); @@ -553,9 +725,9 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f) { case LIST: case LIST_PROPERTY: sb.append(" {\n"); if (f.kind.kind == PropertyTypeKind.Kind.LIST) { - sb.append(" java.util.List _src = o.").append(f.name).append(";\n"); + sb.append(" java.util.List _src = ").append(read).append(";\n"); } else { - sb.append(" java.util.List _src = o.").append(f.name).append(".asList();\n"); + sb.append(" java.util.List _src = ").append(read).append(".asList();\n"); } sb.append(" if (_src != null) {\n"); sb.append(" for (Object _e : _src) {\n"); @@ -582,12 +754,12 @@ private static void emitFieldToXml(StringBuilder sb, MappedField f) { } } - private static void emitFieldFromXml(StringBuilder sb, MappedField f) { + private static void emitFieldFromXml(StringBuilder sb, MappedField f, boolean isRecord) { if (f.xmlAttribute) { sb.append(" {\n"); sb.append(" String _s = root.getAttribute(\"").append(escape(f.xmlName)).append("\");\n"); sb.append(" if (_s != null) {\n"); - emitScalarFromString(sb, f, "_s"); + emitScalarFromString(sb, f, "_s", isRecord); sb.append(" }\n"); sb.append(" }\n"); return; @@ -601,13 +773,16 @@ private static void emitFieldFromXml(StringBuilder sb, MappedField f) { sb.append(" com.codename1.xml.Element _ch = (com.codename1.xml.Element) _kids.elementAt(_i);\n"); emitListElementFromXml(sb, f, "_ch", "_l"); sb.append(" }\n"); - sb.append(" o.").append(f.name).append(" = _l;\n"); + sb.append(" ").append(writeStmt(f, isRecord, "_l")).append("\n"); break; case LIST_PROPERTY: - sb.append(" o.").append(f.name).append(".clear();\n"); + // Rejected upstream for records; reads through `readExpr` + // which yields `o.name` for POJO public fields or + // `o.getName()` for JavaBeans accessor POJOs. + sb.append(" ").append(readExpr(f, isRecord)).append(".clear();\n"); sb.append(" for (int _i = 0; _i < _kids.size(); _i++) {\n"); sb.append(" com.codename1.xml.Element _ch = (com.codename1.xml.Element) _kids.elementAt(_i);\n"); - emitListElementFromXml(sb, f, "_ch", "o." + f.name); + emitListElementFromXml(sb, f, "_ch", readExpr(f, isRecord)); sb.append(" }\n"); break; default: @@ -615,11 +790,11 @@ private static void emitFieldFromXml(StringBuilder sb, MappedField f) { sb.append(" com.codename1.xml.Element _e = (com.codename1.xml.Element) _kids.elementAt(0);\n"); if (f.kind.kind == PropertyTypeKind.Kind.REFERENCE) { sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); - sb.append(" if (_nm != null) o.").append(f.name).append(" = (").append(f.kind.binaryName).append(") _nm.readXml(_e);\n"); + sb.append(" if (_nm != null) ").append(writeStmt(f, isRecord, "(" + f.kind.binaryName + ") _nm.readXml(_e)")).append("\n"); } else { sb.append(" String _s = textOf(_e);\n"); sb.append(" if (_s != null) {\n"); - emitScalarFromString(sb, f, "_s"); + emitScalarFromString(sb, f, "_s", isRecord); sb.append(" }\n"); } sb.append(" }\n"); @@ -658,24 +833,25 @@ private static void emitListElementFromXml(StringBuilder sb, MappedField f, Stri } } - private static void emitScalarToString(StringBuilder sb, MappedField f) { + private static void emitScalarToString(StringBuilder sb, MappedField f, boolean isRecord) { + String read = readExpr(f, isRecord); switch (f.kind.kind) { case STRING: - sb.append("o.").append(f.name); break; + sb.append(read); break; case INT: case LONG: case SHORT: case BYTE: case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: - sb.append("String.valueOf(o.").append(f.name).append(")"); break; + sb.append("String.valueOf(").append(read).append(")"); break; case DATE: - sb.append("o.").append(f.name).append(" == null ? null : String.valueOf(o.").append(f.name).append(".getTime())"); break; + sb.append(read).append(" == null ? null : String.valueOf(").append(read).append(".getTime())"); break; case BYTE_ARRAY: - sb.append("o.").append(f.name).append(" == null ? null : com.codename1.util.Base64.encode(o.").append(f.name).append(")"); break; + sb.append(read).append(" == null ? null : com.codename1.util.Base64.encode(").append(read).append(")"); break; case PROPERTY: - sb.append("o.").append(f.name).append(".get() == null ? null : "); + sb.append(read).append(".get() == null ? null : "); String elem = f.kind.elementBinaryName; if ("java.util.Date".equals(elem)) { - sb.append("String.valueOf(((java.util.Date) o.").append(f.name).append(".get()).getTime())"); + sb.append("String.valueOf(((java.util.Date) ").append(read).append(".get()).getTime())"); } else { - sb.append("String.valueOf(o.").append(f.name).append(".get())"); + sb.append("String.valueOf(").append(read).append(".get())"); } break; default: @@ -683,48 +859,51 @@ private static void emitScalarToString(StringBuilder sb, MappedField f) { } } - private static void emitScalarFromString(StringBuilder sb, MappedField f, String src) { + private static void emitScalarFromString(StringBuilder sb, MappedField f, String src, boolean isRecord) { + // PROPERTY is rejected for records, but `read` keeps the helper safe + // if it ever reaches the record path. + String read = readExpr(f, isRecord); switch (f.kind.kind) { case STRING: - sb.append(" o.").append(f.name).append(" = ").append(src).append(";\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, src)).append("\n"); break; case INT: - sb.append(" o.").append(f.name).append(" = Integer.parseInt(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Integer.parseInt(" + src + ")")).append("\n"); break; case LONG: - sb.append(" o.").append(f.name).append(" = Long.parseLong(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Long.parseLong(" + src + ")")).append("\n"); break; case SHORT: - sb.append(" o.").append(f.name).append(" = Short.parseShort(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Short.parseShort(" + src + ")")).append("\n"); break; case BYTE: - sb.append(" o.").append(f.name).append(" = Byte.parseByte(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Byte.parseByte(" + src + ")")).append("\n"); break; case CHAR: - sb.append(" o.").append(f.name).append(" = ").append(src).append(".length() == 0 ? '\\0' : ").append(src).append(".charAt(0);\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, src + ".length() == 0 ? '\\0' : " + src + ".charAt(0)")).append("\n"); break; case DOUBLE: - sb.append(" o.").append(f.name).append(" = Double.parseDouble(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Double.parseDouble(" + src + ")")).append("\n"); break; case FLOAT: - sb.append(" o.").append(f.name).append(" = Float.parseFloat(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Float.parseFloat(" + src + ")")).append("\n"); break; case BOOLEAN: - sb.append(" o.").append(f.name).append(" = Boolean.parseBoolean(").append(src).append(");\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "Boolean.parseBoolean(" + src + ")")).append("\n"); break; case DATE: - sb.append(" o.").append(f.name).append(" = new java.util.Date(Long.parseLong(").append(src).append("));\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "new java.util.Date(Long.parseLong(" + src + "))")).append("\n"); break; case BYTE_ARRAY: - sb.append(" o.").append(f.name).append(" = com.codename1.util.Base64.decode(").append(src).append(".getBytes());\n"); break; + sb.append(" ").append(writeStmt(f, isRecord, "com.codename1.util.Base64.decode(" + src + ".getBytes())")).append("\n"); break; case PROPERTY: { String elem = f.kind.elementBinaryName; if ("java.lang.String".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(").append(src).append(");\n"); + sb.append(" ").append(read).append(".set(").append(src).append(");\n"); } else if ("java.lang.Integer".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Integer.valueOf(").append(src).append("));\n"); + sb.append(" ").append(read).append(".set(Integer.valueOf(").append(src).append("));\n"); } else if ("java.lang.Long".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Long.valueOf(").append(src).append("));\n"); + sb.append(" ").append(read).append(".set(Long.valueOf(").append(src).append("));\n"); } else if ("java.lang.Double".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Double.valueOf(").append(src).append("));\n"); + sb.append(" ").append(read).append(".set(Double.valueOf(").append(src).append("));\n"); } else if ("java.lang.Float".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Float.valueOf(").append(src).append("));\n"); + sb.append(" ").append(read).append(".set(Float.valueOf(").append(src).append("));\n"); } else if ("java.lang.Boolean".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(Boolean.valueOf(").append(src).append("));\n"); + sb.append(" ").append(read).append(".set(Boolean.valueOf(").append(src).append("));\n"); } else if ("java.util.Date".equals(elem)) { - sb.append(" o.").append(f.name).append(".set(new java.util.Date(Long.parseLong(").append(src).append(")));\n"); + sb.append(" ").append(read).append(".set(new java.util.Date(Long.parseLong(").append(src).append(")));\n"); } else { - sb.append(" o.").append(f.name).append(".set((").append(elem).append(") ").append(src).append(");\n"); + sb.append(" ").append(read).append(".set((").append(elem).append(") ").append(src).append(");\n"); } break; } @@ -768,6 +947,88 @@ private static boolean hasPublicNoArgConstructor(AnnotatedClass cls) { return false; } + /// Finds a public, non-static, no-arg instance accessor returning the + /// field's declared type. Tries the JavaBeans variant first + /// (`getFirstName` for field `firstName`, `isActive` for boolean field + /// `active`), then the literal-prefix variant (`getURL` when the + /// field is `URL`). Returns the method name or null when no accessor + /// matches. + private static String findGetter(AnnotatedClass cls, FieldInfo f, PropertyTypeKind kind) { + String fieldName = f.getName(); + String fieldDesc = f.getDescriptor(); + boolean isBool = "Z".equals(fieldDesc) || "Ljava/lang/Boolean;".equals(fieldDesc); + String beanCap = capitalizeForBean(fieldName); + // Literal-prefix variant -- if `firstName` ⇒ already same as + // beanCap; for `URL` the literal is `URL` itself (no extra cap). + String[] capCandidates; + if (fieldName.equals(beanCap)) { + capCandidates = new String[] { beanCap }; + } else { + capCandidates = new String[] { beanCap, fieldName }; + } + for (String cap : capCandidates) { + if (isBool) { + String name = "is" + cap; + if (hasInstanceMethod(cls, name, "()" + fieldDesc)) return name; + } + String getter = "get" + cap; + if (hasInstanceMethod(cls, getter, "()" + fieldDesc)) return getter; + } + return null; + } + + /// Finds a public, non-static setter `setX(FieldType)` on `cls`. The + /// return type is intentionally ignored (be lenient: builders that + /// return `this` count). Returns the method name or null when no + /// setter matches. + private static String findSetter(AnnotatedClass cls, FieldInfo f) { + String fieldName = f.getName(); + String fieldDesc = f.getDescriptor(); + String beanCap = capitalizeForBean(fieldName); + String[] capCandidates; + if (fieldName.equals(beanCap)) { + capCandidates = new String[] { beanCap }; + } else { + capCandidates = new String[] { beanCap, fieldName }; + } + String paramPrefix = "(" + fieldDesc + ")"; + for (String cap : capCandidates) { + String name = "set" + cap; + for (MethodInfo m : cls.getMethods()) { + if (!name.equals(m.getName())) continue; + if (!m.isPublic() || m.isStatic() || m.isAbstract()) continue; + String d = m.getDescriptor(); + if (d != null && d.startsWith(paramPrefix)) return name; + } + } + return null; + } + + private static boolean hasInstanceMethod(AnnotatedClass cls, String name, String descriptor) { + for (MethodInfo m : cls.getMethods()) { + if (!name.equals(m.getName())) continue; + if (!m.isPublic() || m.isStatic() || m.isAbstract()) continue; + if (descriptor.equals(m.getDescriptor())) return true; + } + return false; + } + + /// JavaBeans capitalisation: lower-case first letter goes upper + /// (`firstName` ⇒ `FirstName`). When the first two letters are both + /// upper-case (e.g. `URL`) the name is left as-is so the literal + /// `getURL` is the primary candidate; the caller probes both. + private static String capitalizeForBean(String name) { + if (name == null || name.length() == 0) return name; + if (name.length() >= 2 + && Character.isUpperCase(name.charAt(0)) + && Character.isUpperCase(name.charAt(1))) { + // Already all-caps prefix -- leave alone (Beans Introspector + // does the same for `URL` → `URL`). + return name; + } + return Character.toUpperCase(name.charAt(0)) + name.substring(1); + } + private static String simpleName(String binary) { int dot = binary.lastIndexOf('.'); return dot < 0 ? binary : binary.substring(dot + 1); @@ -800,6 +1061,10 @@ static final class MappedClass { String mapperBinaryName; String mapperSimpleName; String xmlRootName; + /// `true` for Java 17+ records. Drives accessor-style reads + /// (`o.name()` vs `o.name`) and canonical-constructor writes + /// (`new T(_a, _b, ...)` vs `o.a = ...; o.b = ...`). + boolean isRecord; final List fields = new ArrayList(); } @@ -811,5 +1076,20 @@ static final class MappedField { boolean xmlAttribute; boolean includeInJson; boolean includeInXml; + /// `true` when this POJO field is private but a public + /// `getX()`/`setX(...)` pair (or `isX()` for booleans) was found + /// on the same class. Reads route through the getter, writes + /// route through the setter. Records never set this flag -- + /// records always use the synthesised component accessor and + /// the canonical constructor for writes. + boolean useAccessor; + /// `getFirstName` / `isActive` -- only populated when `useAccessor` + /// is `true`. + String getterName; + /// `setFirstName` -- only populated when `useAccessor` is `true` + /// AND the field is not a `Property` / `ListProperty` (those + /// mutate through their own `.set(...)` / `.add(...)` API; the + /// field reference itself is not replaced). + String setterName; } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java new file mode 100644 index 0000000000..53ee920238 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/RestClientAnnotationProcessor.java @@ -0,0 +1,776 @@ +/* + * 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 java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.objectweb.asm.Type; + +/// Build-time `@RestClient` processor. Scans the project's compiled classes +/// for `@RestClient`-annotated interfaces, validates them (interface, public, +/// every abstract method carries exactly one HTTP-verb annotation, each +/// parameter carries at most one binding annotation), and emits: +/// +/// 1. One `Impl` Java class per `@RestClient` interface in the +/// **same package** as the source interface. The impl chains +/// `com.codename1.io.rest.Rest.(baseUrl + path)` with query / header +/// / body builders and finishes with `fetchAsMapped` / +/// `fetchAsMappedList` / `fetchAsString` based on the +/// `OnComplete>` callback parameter's generic type. +/// 2. A single `cn1app.RestClientBootstrap` whose no-arg constructor calls +/// `com.codename1.io.rest.RestClients.register(FooApi.class, new +/// Factory(){...})` for every accepted interface. The build server +/// probes the project zip for this class and splices +/// `new cn1app.RestClientBootstrap();` into the iOS / Android per-build +/// application stub before `Display.init`, just like the existing +/// `cn1app.MapperBootstrap`. +public final class RestClientAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String REST_CLIENT_DESC = "Lcom/codename1/annotations/rest/RestClient;"; + + public static final String GET_DESC = "Lcom/codename1/annotations/rest/GET;"; + public static final String POST_DESC = "Lcom/codename1/annotations/rest/POST;"; + public static final String PUT_DESC = "Lcom/codename1/annotations/rest/PUT;"; + public static final String DELETE_DESC = "Lcom/codename1/annotations/rest/DELETE;"; + public static final String PATCH_DESC = "Lcom/codename1/annotations/rest/PATCH;"; + + public static final String PATH_DESC = "Lcom/codename1/annotations/rest/Path;"; + public static final String QUERY_DESC = "Lcom/codename1/annotations/rest/Query;"; + public static final String HEADER_DESC = "Lcom/codename1/annotations/rest/Header;"; + public static final String COOKIE_DESC = "Lcom/codename1/annotations/rest/Cookie;"; + public static final String BODY_DESC = "Lcom/codename1/annotations/rest/Body;"; + + static final String BOOTSTRAP_BINARY = "cn1app.RestClientBootstrap"; + static final String BOOTSTRAP_SIMPLE = "RestClientBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(REST_CLIENT_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + /// Accepted interfaces keyed by binary name. TreeMap so the emitted + /// bootstrap registration order is deterministic regardless of 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; + if (cls.getClassAnnotation(REST_CLIENT_DESC) == null) return; + if (!cls.isInterface()) { + ctx.error(cls, "@RestClient requires an interface; " + + cls.getBinaryName() + " is not an interface"); + return; + } + if (!cls.isPublic()) { + ctx.error(cls, "@RestClient interface " + cls.getBinaryName() + + " must be public"); + return; + } + + RestApi api = new RestApi(); + api.binaryName = cls.getBinaryName(); + api.simpleName = simpleName(api.binaryName); + api.packageName = packageOf(api.binaryName); + api.implSimpleName = api.simpleName + "Impl"; + api.implBinaryName = api.packageName.length() == 0 + ? api.implSimpleName + : api.packageName + "." + api.implSimpleName; + + boolean anyError = false; + for (MethodInfo m : cls.getMethods()) { + if (m.isStatic()) continue; // static `of()` factory + if (m.isSynthetic()) continue; + if (m.isConstructor()) continue; // can't happen on interface, defensive + if ((m.getAccess() & org.objectweb.asm.Opcodes.ACC_BRIDGE) != 0) continue; + // Default methods on the interface aren't ours to implement. + if (!m.isAbstract()) continue; + + RestMethod rm = new RestMethod(); + rm.name = m.getName(); + rm.descriptor = m.getDescriptor(); + rm.signature = m.getSignature(); + + String verb = null; + String pathTemplate = null; + int verbCount = 0; + AnnotationValues va; + if ((va = m.getAnnotation(GET_DESC)) != null) { verb = "get"; pathTemplate = va.getString("value"); verbCount++; } + if ((va = m.getAnnotation(POST_DESC)) != null) { verb = "post"; pathTemplate = va.getString("value"); verbCount++; } + if ((va = m.getAnnotation(PUT_DESC)) != null) { verb = "put"; pathTemplate = va.getString("value"); verbCount++; } + if ((va = m.getAnnotation(DELETE_DESC)) != null) { verb = "delete"; pathTemplate = va.getString("value"); verbCount++; } + if ((va = m.getAnnotation(PATCH_DESC)) != null) { verb = "patch"; pathTemplate = va.getString("value"); verbCount++; } + if (verbCount == 0) { + ctx.error(cls, "@RestClient method " + api.binaryName + "." + rm.name + + " must carry exactly one of @GET/@POST/@PUT/@DELETE/@PATCH"); + anyError = true; + continue; + } + if (verbCount > 1) { + ctx.error(cls, "@RestClient method " + api.binaryName + "." + rm.name + + " carries multiple HTTP-verb annotations; pick one"); + anyError = true; + continue; + } + if (pathTemplate == null) pathTemplate = ""; + rm.verb = verb; + rm.pathTemplate = pathTemplate; + + Type[] paramTypes = Type.getArgumentTypes(rm.descriptor); + List> paramAnnotations = m.getParameterAnnotations(); + int paramCount = paramTypes.length; + // Parse generic param signature for the callback's response payload. + String[] genericParamSigs = parseGenericParameterSignatures(rm.signature, paramCount); + + int bodyCount = 0; + int callbackIndex = -1; + boolean methodHasError = false; + + for (int i = 0; i < paramCount; i++) { + RestParam rp = new RestParam(); + rp.index = i; + rp.descriptor = paramTypes[i].getDescriptor(); + rp.javaType = javaTypeFor(paramTypes[i], genericParamSigs == null ? null : genericParamSigs[i]); + rp.name = "p" + i; + + Map pa = i < paramAnnotations.size() + ? paramAnnotations.get(i) : null; + + int annoCount = 0; + AnnotationValues path = null, query = null, header = null, cookie = null; + boolean body = false; + if (pa != null) { + if ((path = pa.get(PATH_DESC)) != null) annoCount++; + if ((query = pa.get(QUERY_DESC)) != null) annoCount++; + if ((header = pa.get(HEADER_DESC)) != null) annoCount++; + if ((cookie = pa.get(COOKIE_DESC)) != null) annoCount++; + if (pa.get(BODY_DESC) != null) { body = true; annoCount++; } + } + if (annoCount > 1) { + ctx.error(cls, "Parameter " + i + " of " + + api.binaryName + "." + rm.name + + " carries multiple REST binding annotations; pick one"); + anyError = true; + methodHasError = true; + break; + } + + if (path != null) { + rp.kind = ParamKind.PATH; + rp.bindingName = path.getString("value"); + if (rp.bindingName == null) rp.bindingName = rp.name; + rp.name = sanitizeIdentifier(rp.bindingName); + } else if (query != null) { + rp.kind = ParamKind.QUERY; + rp.bindingName = query.getString("value"); + if (rp.bindingName == null) rp.bindingName = "p" + i; + rp.name = sanitizeIdentifier(rp.bindingName); + } else if (header != null) { + rp.kind = ParamKind.HEADER; + rp.bindingName = header.getString("value"); + if (rp.bindingName == null) rp.bindingName = "p" + i; + rp.name = sanitizeIdentifier(rp.bindingName + "Header"); + } else if (cookie != null) { + rp.kind = ParamKind.COOKIE; + rp.bindingName = cookie.getString("value"); + if (rp.bindingName == null) rp.bindingName = "p" + i; + rp.name = sanitizeIdentifier(rp.bindingName + "Cookie"); + } else if (body) { + rp.kind = ParamKind.BODY; + rp.name = "body"; + bodyCount++; + } else if (isCallbackType(rp.descriptor)) { + rp.kind = ParamKind.CALLBACK; + rp.name = "callback"; + callbackIndex = i; + rp.javaType = "com.codename1.util.OnComplete>"; + } else { + ctx.error(cls, "Parameter " + i + " of " + + api.binaryName + "." + rm.name + + " has no REST binding annotation and is not an OnComplete callback"); + anyError = true; + methodHasError = true; + break; + } + + rm.params.add(rp); + } + if (methodHasError) continue; + if (bodyCount > 1) { + ctx.error(cls, "@RestClient method " + api.binaryName + "." + rm.name + + " declares more than one @Body parameter"); + anyError = true; + continue; + } + rm.callbackIndex = callbackIndex; + + // Decide payload-fetch shape. + if (callbackIndex >= 0) { + String payloadSig = genericParamSigs == null ? null : genericParamSigs[callbackIndex]; + String payload = extractResponsePayload(payloadSig); + if (payload.startsWith("java.util.List<")) { + rm.fetchKind = FetchKind.MAPPED_LIST; + rm.payloadElementBinaryName = stripGeneric(payload.substring("java.util.List<".length(), + payload.length() - 1)); + } else if ("java.lang.String".equals(payload)) { + rm.fetchKind = FetchKind.STRING; + } else { + rm.fetchKind = FetchKind.MAPPED; + rm.payloadBinaryName = stripGeneric(payload); + } + } else { + rm.fetchKind = FetchKind.STRING; // void w/ no callback + } + + api.methods.add(rm); + } + + if (!anyError) { + accepted.put(api.binaryName, api); + } + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (RestApi api : accepted.values()) { + sources.put(api.implBinaryName, generateImplSource(api)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); + + try { + List cp = new ArrayList(); + cp.add(ctx.getOutputClassDir()); + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException ioe) { + throw new ProcessingException("Could not compile generated @RestClient sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @RestClient impl(s) + " + BOOTSTRAP_BINARY); + } + + // ---------------------------------------------------------------- + // Source generation + // ---------------------------------------------------------------- + + private static String generateImplSource(RestApi api) { + StringBuilder sb = new StringBuilder(4096); + if (api.packageName.length() > 0) { + sb.append("package ").append(api.packageName).append(";\n\n"); + } + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(api.implSimpleName) + .append(" implements ").append(api.binaryName).append(" {\n\n"); + sb.append(" private final String baseUrl;\n\n"); + sb.append(" public ").append(api.implSimpleName).append("(String baseUrl) {\n"); + sb.append(" this.baseUrl = baseUrl;\n"); + sb.append(" }\n\n"); + + for (RestMethod rm : api.methods) { + emitMethod(sb, rm); + } + sb.append("}\n"); + return sb.toString(); + } + + private static void emitMethod(StringBuilder sb, RestMethod rm) { + // Signature + sb.append(" public void ").append(rm.name).append("("); + boolean first = true; + for (RestParam p : rm.params) { + if (!first) sb.append(", "); + sb.append(p.javaType).append(" ").append(p.name); + first = false; + } + sb.append(") {\n"); + + // URL: path-template substitution. + sb.append(" String _url = baseUrl + "); + appendPathExpression(sb, rm); + sb.append(";\n"); + + sb.append(" com.codename1.io.rest.RequestBuilder _rb = com.codename1.io.rest.Rest.") + .append(rm.verb).append("(_url);\n"); + + // Query params. + for (RestParam p : rm.params) { + if (p.kind != ParamKind.QUERY) continue; + sb.append(" if (").append(p.name).append(" != null) _rb.queryParam(\"") + .append(escape(p.bindingName)).append("\", String.valueOf(") + .append(p.name).append("));\n"); + } + // Header params. + for (RestParam p : rm.params) { + if (p.kind != ParamKind.HEADER) continue; + sb.append(" if (").append(p.name).append(" != null) _rb.header(\"") + .append(escape(p.bindingName)).append("\", ").append(p.name).append(");\n"); + } + // Cookie params: collapsed into a single `Cookie: a=1; b=2` header. + boolean anyCookie = false; + for (RestParam p : rm.params) { + if (p.kind == ParamKind.COOKIE) { anyCookie = true; break; } + } + if (anyCookie) { + sb.append(" StringBuilder _ck = new StringBuilder();\n"); + for (RestParam p : rm.params) { + if (p.kind != ParamKind.COOKIE) continue; + sb.append(" if (").append(p.name).append(" != null) {\n"); + sb.append(" if (_ck.length() > 0) _ck.append(\"; \");\n"); + sb.append(" _ck.append(\"").append(escape(p.bindingName)) + .append("=\").append(com.codename1.io.Util.encodeUrl(String.valueOf(") + .append(p.name).append(")));\n"); + sb.append(" }\n"); + } + sb.append(" if (_ck.length() > 0) _rb.header(\"Cookie\", _ck.toString());\n"); + } + // Body. + for (RestParam p : rm.params) { + if (p.kind != ParamKind.BODY) continue; + sb.append(" _rb.contentType(\"application/json\").body(com.codename1.mapping.Mappers.toJson(") + .append(p.name).append("));\n"); + } + + // Fetch. + switch (rm.fetchKind) { + case MAPPED: + sb.append(" _rb.fetchAsMapped(").append(rm.payloadBinaryName).append(".class, callback);\n"); + break; + case MAPPED_LIST: + sb.append(" _rb.fetchAsMappedList(").append(rm.payloadElementBinaryName).append(".class, callback);\n"); + break; + case STRING: + if (rm.callbackIndex >= 0) { + sb.append(" _rb.fetchAsString(callback);\n"); + } else { + sb.append(" _rb.fetchAsString(new com.codename1.util.OnComplete>() {\n"); + sb.append(" public void completed(com.codename1.io.rest.Response _r) { }\n"); + sb.append(" });\n"); + } + break; + } + sb.append(" }\n\n"); + } + + /// Builds the Java expression that resolves the URL path with `{name}` + /// placeholders replaced by the matching `@Path` parameter values + /// (URL-encoded). The result is concatenated onto `baseUrl`. + private static void appendPathExpression(StringBuilder sb, RestMethod rm) { + String path = rm.pathTemplate; + if (path == null) path = ""; + // Split on {name} placeholders and replace each with a concat against the + // matching @Path parameter. + StringBuilder cur = new StringBuilder(); + List parts = new ArrayList(); + List names = new ArrayList(); + int i = 0; + while (i < path.length()) { + char c = path.charAt(i); + if (c == '{') { + int end = path.indexOf('}', i); + if (end < 0) { cur.append(c); i++; continue; } + parts.add(cur.toString()); cur.setLength(0); + names.add(path.substring(i + 1, end)); + i = end + 1; + } else { + cur.append(c); + i++; + } + } + parts.add(cur.toString()); + + if (names.isEmpty()) { + sb.append('"').append(escape(parts.get(0))).append('"'); + return; + } + boolean first = true; + for (int p = 0; p < parts.size(); p++) { + String literal = parts.get(p); + if (literal.length() > 0 || (first && p == 0)) { + if (!first) sb.append(" + "); + sb.append('"').append(escape(literal)).append('"'); + first = false; + } + if (p < names.size()) { + String placeholder = names.get(p); + String paramName = findPathParamName(rm, placeholder); + if (!first) sb.append(" + "); + sb.append("String.valueOf(").append(paramName).append(")"); + first = false; + } + } + } + + private static String findPathParamName(RestMethod rm, String placeholder) { + for (RestParam p : rm.params) { + if (p.kind == ParamKind.PATH && placeholder.equals(p.bindingName)) { + return p.name; + } + } + // Fall back to a no-op constant so generated source still compiles -- + // upstream validation should already have caught the missing @Path. + return "\"" + escape(placeholder) + "\""; + } + + private static String generateBootstrapSource(Iterable apis) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("///\n"); + sb.append("/// REST-client bootstrap. The iOS / Android per-build application\n"); + sb.append("/// stub instantiates this class before Display.init (the build\n"); + sb.append("/// server probes the project zip for it and emits the install\n"); + sb.append("/// line conditionally); JavaSEPort.postInit picks it up via\n"); + sb.append("/// Class.forName for the simulator and desktop runs.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(BOOTSTRAP_SIMPLE).append(" {\n"); + sb.append(" public ").append(BOOTSTRAP_SIMPLE).append("() {\n"); + for (RestApi api : apis) { + sb.append(" com.codename1.io.rest.RestClients.register(") + .append(api.binaryName).append(".class, new com.codename1.io.rest.RestClients.Factory<") + .append(api.binaryName).append(">() {\n"); + sb.append(" public ").append(api.binaryName).append(" create(String baseUrl) {\n"); + sb.append(" return new ").append(api.implBinaryName).append("(baseUrl);\n"); + sb.append(" }\n"); + sb.append(" });\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // ---------------------------------------------------------------- + // Signature parsing + // ---------------------------------------------------------------- + + /// Splits the parameter portion of a JVM generic method signature into one + /// substring per parameter. Returns null when no signature is available + /// (raw method) so callers fall back to the descriptor. + static String[] parseGenericParameterSignatures(String signature, int expectedCount) { + if (signature == null) return null; + int open = signature.indexOf('('); + int close = matchingParen(signature, open); + if (open < 0 || close < 0) return null; + String params = signature.substring(open + 1, close); + List out = new ArrayList(); + int i = 0; + while (i < params.length()) { + int start = i; + char c = params.charAt(i); + // skip array prefix + while (c == '[') { i++; if (i >= params.length()) break; c = params.charAt(i); } + if (c == 'L' || c == 'T') { + i = skipReferenceTypeSignature(params, i); + } else { + // primitive: single char + i++; + } + out.add(params.substring(start, i)); + } + if (out.size() != expectedCount) return null; + return out.toArray(new String[0]); + } + + private static int skipReferenceTypeSignature(String s, int i) { + // L(...generic args...); OR T; + char c = s.charAt(i); + if (c == 'T') { + int semi = s.indexOf(';', i); + return semi < 0 ? s.length() : semi + 1; + } + // Walk forward counting < > nesting so we find the matching ';' on + // the *outer* type. JVM signatures spell nested generics inline so + // `Lcom/foo/Bar;>;` is one + // signature; tracking the depth keeps us from stopping at the inner + // `;` characters. + int depth = 0; + i++; // past 'L' + while (i < s.length()) { + char ch = s.charAt(i); + if (ch == '<') depth++; + else if (ch == '>') depth--; + else if (ch == ';' && depth == 0) return i + 1; + i++; + } + return s.length(); + } + + private static int matchingParen(String s, int open) { + if (open < 0) return -1; + int depth = 0; + for (int i = open; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '(') depth++; + else if (c == ')') { depth--; if (depth == 0) return i; } + } + return -1; + } + + /// Returns the Java (binary) form of the payload type carried by the + /// callback parameter. Given the parameter's generic signature + /// `Lcom/codename1/util/OnComplete;>;` + /// this returns `pet.Pet`. Returns `java.lang.Object` when the signature + /// can't be parsed. + static String extractResponsePayload(String paramSignature) { + if (paramSignature == null) return "java.lang.Object"; + // Find the OnComplete generic arg. + int lt = paramSignature.indexOf('<'); + int gt = paramSignature.lastIndexOf('>'); + if (lt < 0 || gt < 0 || lt > gt) return "java.lang.Object"; + String inner = paramSignature.substring(lt + 1, gt); // Lcom/codename1/io/rest/Response<...>; + // Now find the Response's inner type arg. + int innerLt = inner.indexOf('<'); + int innerGt = inner.lastIndexOf('>'); + if (innerLt < 0 || innerGt < 0 || innerLt > innerGt) return "java.lang.Object"; + String payload = inner.substring(innerLt + 1, innerGt); + return jvmSignatureToJavaType(payload); + } + + private static String jvmSignatureToJavaType(String sig) { + if (sig == null || sig.length() == 0) return "java.lang.Object"; + char c = sig.charAt(0); + switch (c) { + case 'V': return "void"; + case 'B': return "byte"; + case 'C': return "char"; + case 'D': return "double"; + case 'F': return "float"; + case 'I': return "int"; + case 'J': return "long"; + case 'S': return "short"; + case 'Z': return "boolean"; + case '[': + return jvmSignatureToJavaType(sig.substring(1)) + "[]"; + case 'L': + // Lpkg/Class<...args>; + int end = sig.indexOf('<'); + int semi = sig.indexOf(';'); + if (end < 0 || (semi >= 0 && semi < end)) { + // No generic args. + String binary = sig.substring(1, semi >= 0 ? semi : sig.length() - 1).replace('/', '.'); + return binary; + } + String rawBin = sig.substring(1, end).replace('/', '.'); + // Find matching '>' + int depth = 0; + int gt = -1; + for (int i = end; i < sig.length(); i++) { + char ch = sig.charAt(i); + if (ch == '<') depth++; + else if (ch == '>') { depth--; if (depth == 0) { gt = i; break; } } + } + if (gt < 0) return rawBin; + String args = sig.substring(end + 1, gt); + // Split args at top level. + List argList = splitTopLevelArgs(args); + StringBuilder sb = new StringBuilder(rawBin); + sb.append('<'); + for (int i = 0; i < argList.size(); i++) { + if (i > 0) sb.append(", "); + String a = argList.get(i); + if (a.startsWith("*")) { + sb.append("?"); + } else if (a.startsWith("+")) { + sb.append("? extends ").append(boxIfPrimitive(jvmSignatureToJavaType(a.substring(1)))); + } else if (a.startsWith("-")) { + sb.append("? super ").append(boxIfPrimitive(jvmSignatureToJavaType(a.substring(1)))); + } else { + sb.append(boxIfPrimitive(jvmSignatureToJavaType(a))); + } + } + sb.append('>'); + return sb.toString(); + case 'T': + // Type variable -- erased to its name; we treat as Object. + return "java.lang.Object"; + default: + return "java.lang.Object"; + } + } + + private static List splitTopLevelArgs(String args) { + List out = new ArrayList(); + int i = 0; + while (i < args.length()) { + int start = i; + char c = args.charAt(i); + if (c == '*') { out.add("*"); i++; continue; } + if (c == '+' || c == '-') { i++; if (i >= args.length()) break; c = args.charAt(i); } + while (c == '[') { i++; if (i >= args.length()) break; c = args.charAt(i); } + if (c == 'L' || c == 'T') { + i = skipReferenceTypeSignature(args, i); + } else { + i++; + } + out.add(args.substring(start, i)); + } + return out; + } + + /// Strips top-level generic parameters from a Java type name so it can be + /// used as a `Class` literal. `List` -> `List`. + private static String stripGeneric(String javaType) { + if (javaType == null) return "java.lang.Object"; + int lt = javaType.indexOf('<'); + return lt < 0 ? javaType : javaType.substring(0, lt); + } + + private static boolean isCallbackType(String descriptor) { + return "Lcom/codename1/util/OnComplete;".equals(descriptor); + } + + /// Returns the Java type name for a parameter, preferring the generic + /// signature when available so `List` survives instead of erasing to + /// `List`. + private static String javaTypeFor(Type asmType, String genericSig) { + if (genericSig != null && genericSig.length() > 0) { + return jvmSignatureToJavaType(genericSig); + } + // Erase to the descriptor form. + return jvmSignatureToJavaType(asmType.getDescriptor()); + } + + private static String boxIfPrimitive(String type) { + if (type == null) return "java.lang.Object"; + if (type.equals("int")) return "java.lang.Integer"; + if (type.equals("long")) return "java.lang.Long"; + if (type.equals("double")) return "java.lang.Double"; + if (type.equals("float")) return "java.lang.Float"; + if (type.equals("boolean")) return "java.lang.Boolean"; + if (type.equals("byte")) return "java.lang.Byte"; + if (type.equals("short")) return "java.lang.Short"; + if (type.equals("char")) return "java.lang.Character"; + return type; + } + + // ---------------------------------------------------------------- + // Misc + // ---------------------------------------------------------------- + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + private static String simpleName(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String escape(String s) { + if (s == null) return ""; + StringBuilder b = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') b.append('\\'); + b.append(c); + } + return b.toString(); + } + + private static String sanitizeIdentifier(String s) { + if (s == null || s.length() == 0) return "p"; + StringBuilder b = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (i == 0 ? Character.isJavaIdentifierStart(c) : Character.isJavaIdentifierPart(c)) { + b.append(c); + } + } + if (b.length() == 0) return "p"; + return b.toString(); + } + + // ---------------------------------------------------------------- + // Accumulators + // ---------------------------------------------------------------- + + enum ParamKind { PATH, QUERY, HEADER, COOKIE, BODY, CALLBACK } + + enum FetchKind { MAPPED, MAPPED_LIST, STRING } + + static final class RestApi { + String binaryName; + String packageName; + String simpleName; + String implBinaryName; + String implSimpleName; + final List methods = new ArrayList(); + } + + static final class RestMethod { + String name; + String descriptor; + String signature; + String verb; + String pathTemplate; + int callbackIndex = -1; + FetchKind fetchKind; + String payloadBinaryName; // FetchKind.MAPPED + String payloadElementBinaryName; // FetchKind.MAPPED_LIST + final List params = new ArrayList(); + } + + static final class RestParam { + int index; + String name; + String descriptor; + String javaType; + ParamKind kind; + String bindingName; // path / query / header name + } +} 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 index be46f92207..c40525751f 100644 --- 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 @@ -2,3 +2,4 @@ com.codename1.maven.processors.RouteAnnotationProcessor com.codename1.maven.processors.MappingAnnotationProcessor com.codename1.maven.processors.BindingAnnotationProcessor com.codename1.maven.processors.OrmAnnotationProcessor +com.codename1.maven.processors.RestClientAnnotationProcessor diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java new file mode 100644 index 0000000000..f1a92cba6c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/GenerateOpenApiMojoTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.maven; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/// Drives the OpenAPI codegen against an inline JSON spec to verify model + +/// `@RestClient` emission shape. Both the Java 17 records path and the Java 8 +/// classes path are exercised. +class GenerateOpenApiMojoTest { + + private static final String SAMPLE_SPEC = + "{" + + "\"openapi\":\"3.0.0\"," + + "\"info\":{\"title\":\"Petstore\",\"version\":\"1.0\"}," + + "\"paths\":{" + + " \"/pet/{petId}\":{" + + " \"get\":{" + + " \"tags\":[\"Pet\"]," + + " \"operationId\":\"getPetById\"," + + " \"parameters\":[{\"name\":\"petId\",\"in\":\"path\",\"required\":true," + + " \"schema\":{\"type\":\"integer\",\"format\":\"int64\"}}]," + + " \"responses\":{\"200\":{" + + " \"description\":\"ok\"," + + " \"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Pet\"}}}" + + " }}" + + " }" + + " }," + + " \"/pets\":{" + + " \"get\":{" + + " \"tags\":[\"Pet\"]," + + " \"operationId\":\"findPets\"," + + " \"responses\":{\"200\":{" + + " \"description\":\"ok\"," + + " \"content\":{\"application/json\":{\"schema\":{" + + " \"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Pet\"}" + + " }}}" + + " }}" + + " }" + + " }" + + "}," + + "\"components\":{\"schemas\":{" + + " \"Pet\":{" + + " \"type\":\"object\"," + + " \"properties\":{" + + " \"id\":{\"type\":\"integer\",\"format\":\"int64\"}," + + " \"name\":{\"type\":\"string\"}" + + " }" + + " }," + + " \"Cat\":{" + + " \"type\":\"object\"," + + " \"properties\":{" + + " \"id\":{\"type\":\"integer\",\"format\":\"int64\"}," + + " \"name\":{\"type\":\"string\"}" + + " }" + + " }" + + "}}" + + "}"; + + @Test + void emitsRecordsAndRestClientInterface(@TempDir Path tmp) throws Exception { + Map doc = parse(SAMPLE_SPEC); + File out = tmp.toFile(); + GenerateOpenApiMojo.Generator gen = new GenerateOpenApiMojo.Generator( + doc, "com.example.petstore", out, /*overwrite*/ true, + /*emitRecords*/ true, new SystemStreamLog()); + gen.run(); + + File petJava = new File(out, "com/example/petstore/model/Pet.java"); + assertTrue(petJava.exists(), "expected Pet.java at " + petJava); + String petSrc = readString(petJava); + assertTrue(petSrc.contains("@Mapped"), "Pet should be @Mapped"); + assertTrue(petSrc.contains("public record Pet("), "Pet should be a record on Java 17 target"); + assertTrue(petSrc.contains("@JsonProperty(\"id\") Long id"), + "Pet record should declare @JsonProperty(\"id\") Long id; was:\n" + petSrc); + assertTrue(petSrc.contains("@JsonProperty(\"name\") String name"), + "Pet record should declare name; was:\n" + petSrc); + + // Cat is structurally identical to Pet and should collapse to Pet -- + // i.e. Cat.java should NOT be emitted as a separate record. + File catJava = new File(out, "com/example/petstore/model/Cat.java"); + assertFalse(catJava.exists(), + "Cat is structurally identical to Pet -- expected schema unification to drop it"); + + File petApi = new File(out, "com/example/petstore/PetApi.java"); + assertTrue(petApi.exists(), "expected PetApi.java at " + petApi); + String apiSrc = readString(petApi); + assertTrue(apiSrc.contains("@RestClient"), "PetApi should carry @RestClient"); + assertTrue(apiSrc.contains("public interface PetApi"), "PetApi must be an interface"); + assertTrue(apiSrc.contains("@GET(\"/pet/{petId}\")"), + "getPetById method should carry @GET(\"/pet/{petId}\"); was:\n" + apiSrc); + assertTrue(apiSrc.contains("@Path(\"petId\") Long petId"), + "getPetById path param shape; was:\n" + apiSrc); + assertTrue(apiSrc.contains("@Header(\"Authorization\") String bearerToken"), + "bearerToken header shape; was:\n" + apiSrc); + assertTrue(apiSrc.contains("OnComplete> callback"), + "callback shape; was:\n" + apiSrc); + assertTrue(apiSrc.contains("OnComplete>> callback"), + "findPets list-of-Pet response; was:\n" + apiSrc); + assertTrue(apiSrc.contains("static PetApi of(String baseUrl)"), + "static of(...) factory must be emitted"); + assertTrue(apiSrc.contains("RestClients.create(PetApi.class, baseUrl)"), + "of(...) factory should delegate to RestClients.create"); + } + + @Test + void emitsClassesOnJava8Target(@TempDir Path tmp) throws Exception { + Map doc = parse(SAMPLE_SPEC); + File out = tmp.toFile(); + GenerateOpenApiMojo.Generator gen = new GenerateOpenApiMojo.Generator( + doc, "com.example.petstore", out, true, + /*emitRecords*/ false, new SystemStreamLog()); + gen.run(); + + String petSrc = readString(new File(out, "com/example/petstore/model/Pet.java")); + assertTrue(petSrc.contains("public class Pet {"), "Pet should be a class on Java 8 target"); + assertTrue(petSrc.contains("public Long id;"), "Pet class should declare Long id field"); + assertTrue(petSrc.contains("public String name;"), "Pet class should declare String name field"); + assertTrue(petSrc.contains("public Pet() {}"), "Pet class should have a public no-arg ctor"); + } + + @Test + void respectsOverwriteFalseAndPreservesUserEdits(@TempDir Path tmp) throws Exception { + Map doc = parse(SAMPLE_SPEC); + File out = tmp.toFile(); + File apiDir = new File(out, "com/example/petstore"); + if (!apiDir.exists() && !apiDir.mkdirs()) throw new IOException("mkdirs"); + File apiFile = new File(apiDir, "PetApi.java"); + Files.write(apiFile.toPath(), "// hand-edited".getBytes(StandardCharsets.UTF_8)); + + GenerateOpenApiMojo.Generator gen = new GenerateOpenApiMojo.Generator( + doc, "com.example.petstore", out, /*overwrite*/ false, + true, new SystemStreamLog()); + gen.run(); + + String apiSrc = readString(apiFile); + assertTrue(apiSrc.startsWith("// hand-edited"), + "overwrite=false should preserve user edits; was:\n" + apiSrc); + } + + @Test + void parseJavaVersionHandlesShapes() { + org.junit.jupiter.api.Assertions.assertEquals(8, GenerateOpenApiMojo.parseJavaVersion("1.8")); + org.junit.jupiter.api.Assertions.assertEquals(8, GenerateOpenApiMojo.parseJavaVersion(null)); + org.junit.jupiter.api.Assertions.assertEquals(11, GenerateOpenApiMojo.parseJavaVersion("11")); + org.junit.jupiter.api.Assertions.assertEquals(17, GenerateOpenApiMojo.parseJavaVersion("17")); + org.junit.jupiter.api.Assertions.assertEquals(21, GenerateOpenApiMojo.parseJavaVersion("21-LTS")); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private static Map parse(String json) throws IOException { + return new com.codename1.io.JSONParser().parseJSON(new StringReader(json)); + } + + private static String readString(File f) throws IOException { + return new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java new file mode 100644 index 0000000000..ff00816daf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/JavaBeansAccessorMappingTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +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 java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/// JavaBeans-accessor support for `MappingAnnotationProcessor`. Compiles a +/// POJO with private fields plus public `getX()` / `setX()` (and `isX()` +/// for booleans) accessors, runs the processor, and asserts that the +/// generated mapper: +/// +/// - reads through the bean accessors (`o.getFirstName()`, `o.isActive()`) +/// rather than touching the (private) field directly; +/// - writes through the matching setters (`o.setFirstName(...)`, +/// `o.setActive(...)`). +/// +/// The test also round-trips a real instance through `toMap` / `fromMap` +/// to lock in semantic correctness, not just textual shape. +public class JavaBeansAccessorMappingTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void privatePojoFieldsRouteThroughBeanAccessors() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.User", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped\n" + + "public class User {\n" + + " private String firstName;\n" + + " private int age;\n" + + " private boolean active;\n" + + " public User() {}\n" + + " public String getFirstName() { return firstName; }\n" + + " public void setFirstName(String v) { firstName = v; }\n" + + " public int getAge() { return age; }\n" + + " public void setAge(int v) { age = v; }\n" + + " public boolean isActive() { return active; }\n" + + " public void setActive(boolean v) { active = v; }\n" + + "}\n"), + classes, + Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + assertFalse("processor reported errors: " + ctx.getErrors(), ctx.hasErrors()); + + File mapperClass = new File(classes, "com/example/UserCn1Mapper.class"); + assertTrue("generated mapper class should exist", mapperClass.exists()); + + // Source-shape inspection: re-run the source-emit path against + // the same scanned MappedClass so we can grep the literal text. + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx2 = new ProcessorContext(classes, tmp.newFolder(), + ClassScanner.scan(classes), new SystemStreamLog()); + proc.start(ctx2); + for (AnnotatedClass cls : ClassScanner.scan(classes).values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx2); + } + String generated = invokeGenerateMapperSource(proc); + assertNotNull("expected generated source for User POJO", generated); + + // Reads route through bean getters. + assertTrue("toMap should call o.getFirstName(): " + generated, + generated.contains("o.getFirstName()")); + assertTrue("toMap should call o.getAge(): " + generated, + generated.contains("o.getAge()")); + assertTrue("toMap should call o.isActive() for boolean: " + generated, + generated.contains("o.isActive()")); + + // Writes route through bean setters. + assertTrue("fromMap should call o.setFirstName(...): " + generated, + generated.contains("o.setFirstName(")); + assertTrue("fromMap should call o.setAge(...): " + generated, + generated.contains("o.setAge(")); + assertTrue("fromMap should call o.setActive(...): " + generated, + generated.contains("o.setActive(")); + + // Direct field access must NOT appear -- the fields are private. + assertFalse("must not emit direct field access o.firstName: " + generated, + generated.contains("o.firstName")); + assertFalse("must not emit direct field access o.age: " + generated, + generated.contains("o.age")); + assertFalse("must not emit direct field access o.active: " + generated, + generated.contains("o.active")); + + // End-to-end round-trip. + try (URLClassLoader cl = childLoader(classes)) { + Class userCls = cl.loadClass("com.example.User"); + Class mapperCls = cl.loadClass("com.example.UserCn1Mapper"); + Object mapper = mapperCls.newInstance(); + + Object user = userCls.newInstance(); + userCls.getMethod("setFirstName", String.class).invoke(user, "Alice"); + userCls.getMethod("setAge", int.class).invoke(user, 31); + userCls.getMethod("setActive", boolean.class).invoke(user, true); + + Method toMap = mapperCls.getMethod("toMap", userCls); + @SuppressWarnings("unchecked") + Map json = (Map) toMap.invoke(mapper, user); + assertEquals("Alice", json.get("firstName")); + assertEquals(Integer.valueOf(31), json.get("age")); + assertEquals(Boolean.TRUE, json.get("active")); + + Map in = new LinkedHashMap(); + in.put("firstName", "Bob"); + in.put("age", Integer.valueOf(7)); + in.put("active", Boolean.FALSE); + Method fromMap = mapperCls.getMethod("fromMap", Map.class); + Object restored = fromMap.invoke(mapper, in); + assertNotNull(restored); + assertEquals("Bob", userCls.getMethod("getFirstName").invoke(restored)); + assertEquals(7, ((Integer) userCls.getMethod("getAge").invoke(restored)).intValue()); + assertEquals(Boolean.FALSE, userCls.getMethod("isActive").invoke(restored)); + } + } + + // --------------------------------------------------------------- + // Helpers (mirrors RecordMappingTest) + // --------------------------------------------------------------- + + private static String invokeGenerateMapperSource(MappingAnnotationProcessor proc) throws Exception { + java.lang.reflect.Field acceptedFld = MappingAnnotationProcessor.class.getDeclaredField("accepted"); + acceptedFld.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.TreeMap accepted = (java.util.TreeMap) acceptedFld.get(proc); + Object mc = accepted.values().iterator().next(); + Method m = MappingAnnotationProcessor.class + .getDeclaredMethod("generateMapperSource", + Class.forName("com.codename1.maven.processors.MappingAnnotationProcessor$MappedClass")); + m.setAccessible(true); + return (String) m.invoke(null, mc); + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + private URLClassLoader childLoader(File classesDir) throws Exception { + URL[] urls = new URL[] { + classesDir.toURI().toURL(), + testClassesDir().toURI().toURL() + }; + return new URLClassLoader(urls, getClass().getClassLoader()); + } + + private static File testClassesDir() throws Exception { + URL url = JavaBeansAccessorMappingTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java new file mode 100644 index 0000000000..b4d3f41d10 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RecordMappingTest.java @@ -0,0 +1,176 @@ +/* + * 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 java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/// Records support for `MappingAnnotationProcessor`. Compiles a tiny +/// `record Pet(String name, int age) {}` annotated `@Mapped`, runs the +/// processor, then exercises the generated mapper through a child +/// classloader (we can't refer to the generated class directly because +/// it doesn't exist until the processor runs). +/// +/// Records are a Java 16+ feature; on older JVMs the source-compile step +/// can't be invoked at all, so the test is skipped via JUnit Assume. +public class RecordMappingTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void recordRoundTripsThroughGeneratedMapper() throws Exception { + assumeTrue("records require Java 16+", javaSpecAtLeast(16)); + + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Pet", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped\n" + + "public record Pet(String name, int age) {}\n"), + classes, + Arrays.asList(testClassesDir())); + + // Run the processor and capture the generated mapper source so we + // can assert on its shape (accessor reads + canonical ctor call). + ProcessorContext ctx = runProcessor(classes); + assertTrue("processor reported errors: " + ctx.getErrors(), !ctx.hasErrors()); + + File mapperClass = new File(classes, "com/example/PetCn1Mapper.class"); + assertTrue("generated mapper class should exist", mapperClass.exists()); + + // Direct source-shape inspection: re-run the source-emit path + // against the same scanned MappedClass so we can grep the literal + // text. This is the contract the task asks us to lock in. + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx2 = new ProcessorContext(classes, tmp.newFolder(), + ClassScanner.scan(classes), new SystemStreamLog()); + proc.start(ctx2); + for (AnnotatedClass cls : ClassScanner.scan(classes).values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx2); + } + String generated = invokeGenerateMapperSource(proc); + assertNotNull("expected generated source for Pet record", generated); + assertTrue("fromMap should construct via canonical ctor: " + generated, + generated.contains("new com.example.Pet(_name, _age)")); + assertTrue("toMap should read via record accessor o.name(): " + generated, + generated.contains("o.name()")); + assertTrue("toMap should read via record accessor o.age(): " + generated, + generated.contains("o.age()")); + + // End-to-end: load the generated mapper and round-trip a Pet. + try (URLClassLoader cl = childLoader(classes)) { + Class petCls = cl.loadClass("com.example.Pet"); + Class mapperCls = cl.loadClass("com.example.PetCn1Mapper"); + Object mapper = mapperCls.newInstance(); + + // Construct a Pet via the canonical constructor. + Object pet = petCls.getConstructor(String.class, int.class) + .newInstance("Fido", 4); + + Method toMap = mapperCls.getMethod("toMap", petCls); + @SuppressWarnings("unchecked") + Map json = (Map) toMap.invoke(mapper, pet); + assertEquals("Fido", json.get("name")); + assertEquals(Integer.valueOf(4), json.get("age")); + + Map in = new LinkedHashMap(); + in.put("name", "Rex"); + in.put("age", Integer.valueOf(7)); + Method fromMap = mapperCls.getMethod("fromMap", Map.class); + Object restored = fromMap.invoke(mapper, in); + assertNotNull(restored); + // Read back via the synthesised record accessors. + assertEquals("Rex", petCls.getMethod("name").invoke(restored)); + assertEquals(7, ((Integer) petCls.getMethod("age").invoke(restored)).intValue()); + } + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /// Pulls the package-private `generateMapperSource(MappedClass)` out + /// of `MappingAnnotationProcessor` via reflection so the test can + /// grep the emitted source text. We do this so the assertion is + /// against the source the processor would compile, not against the + /// resulting `.class` bytecode (where field/method calls are harder + /// to spot). + private static String invokeGenerateMapperSource(MappingAnnotationProcessor proc) throws Exception { + // Walk the proc's accepted map -- there is exactly one entry for + // this test (com.example.Pet). + java.lang.reflect.Field acceptedFld = MappingAnnotationProcessor.class.getDeclaredField("accepted"); + acceptedFld.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.TreeMap accepted = (java.util.TreeMap) acceptedFld.get(proc); + Object mc = accepted.values().iterator().next(); + Method m = MappingAnnotationProcessor.class + .getDeclaredMethod("generateMapperSource", + Class.forName("com.codename1.maven.processors.MappingAnnotationProcessor$MappedClass")); + m.setAccessible(true); + return (String) m.invoke(null, mc); + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + private URLClassLoader childLoader(File classesDir) throws Exception { + URL[] urls = new URL[] { + classesDir.toURI().toURL(), + testClassesDir().toURI().toURL() + }; + return new URLClassLoader(urls, getClass().getClassLoader()); + } + + private static File testClassesDir() throws Exception { + URL url = RecordMappingTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } + + private static boolean javaSpecAtLeast(int target) { + String spec = System.getProperty("java.specification.version", ""); + try { + // Java 9+ reports a major-only spec like "17"; Java 8 reports "1.8". + if (spec.startsWith("1.")) { + return Integer.parseInt(spec.substring(2)) >= target; + } + return Integer.parseInt(spec) >= target; + } catch (NumberFormatException nfe) { + return false; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java new file mode 100644 index 0000000000..ba34655c7e --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/RestClientAnnotationProcessorTest.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +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 java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// End-to-end test for `RestClientAnnotationProcessor`. Compiles a +/// `@RestClient` interface alongside a fixture `@Mapped` POJO, runs the +/// processor, then asserts both the impl class and the bootstrap have been +/// produced -- and the impl class's source carries the expected +/// `Rest.` + `fetchAsMapped` invocations. +public class RestClientAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void emitsImplAndBootstrapForPetApi() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = new java.util.LinkedHashMap(); + sources.put("com.example.Pet", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class Pet {\n" + + " public Long id;\n" + + " public String name;\n" + + " public Pet() {}\n" + + "}\n"); + sources.put("com.example.PetApi", + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface PetApi {\n" + + " @GET(\"/pet/{petId}\")\n" + + " void getPetById(@Path(\"petId\") Long petId,\n" + + " @Header(\"Authorization\") String bearerToken,\n" + + " OnComplete> callback);\n" + + " @POST(\"/pet\")\n" + + " void addPet(@Body Pet body,\n" + + " @Header(\"Authorization\") String bearerToken,\n" + + " OnComplete> callback);\n" + + " @GET(\"/pets\")\n" + + " void findAll(@Query(\"status\") String status,\n" + + " OnComplete>> callback);\n" + + " static PetApi of(String baseUrl) {\n" + + " return com.codename1.io.rest.RestClients.create(PetApi.class, baseUrl);\n" + + " }\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + + // The processor compiled and wrote out PetApiImpl + RestClientBootstrap. + File impl = new File(classes, "com/example/PetApiImpl.class"); + File boot = new File(classes, "cn1app/RestClientBootstrap.class"); + assertTrue("expected PetApiImpl.class at " + impl, impl.exists()); + assertTrue("expected RestClientBootstrap.class at " + boot, boot.exists()); + + // Re-run the processor against a fresh sources map to recover the in-memory + // Java source so we can string-search the generated body. This is the + // simplest way to assert on what was generated without exposing the + // emitted-sources map outside the processor. + String implSrc = generateImplSourceForFixture(classes); + assertTrue("getPetById should call Rest.get", + implSrc.contains("com.codename1.io.rest.Rest.get(_url)")); + assertTrue("getPetById should embed path param via String.valueOf", + implSrc.contains("\"/pet/\" + String.valueOf(petId)")); + assertTrue("getPetById should attach Authorization header", + implSrc.contains("_rb.header(\"Authorization\", AuthorizationHeader)")); + assertTrue("getPetById should fetch as mapped Pet", + implSrc.contains("_rb.fetchAsMapped(com.example.Pet.class, callback)")); + + assertTrue("addPet should call Rest.post", + implSrc.contains("com.codename1.io.rest.Rest.post(_url)")); + assertTrue("addPet should serialize body via Mappers.toJson", + implSrc.contains("com.codename1.mapping.Mappers.toJson(body)")); + + assertTrue("findAll should append status query param", + implSrc.contains("_rb.queryParam(\"status\", String.valueOf(status))")); + assertTrue("findAll should fetch as mapped list", + implSrc.contains("_rb.fetchAsMappedList(com.example.Pet.class, callback)")); + + // Bootstrap registers our PetApi. + String bootSrc = generateBootstrapSourceForFixture(classes); + assertTrue("bootstrap should register PetApi", + bootSrc.contains("RestClients.register(com.example.PetApi.class")); + assertTrue("bootstrap should instantiate PetApiImpl", + bootSrc.contains("new com.example.PetApiImpl(baseUrl)")); + } + + @Test + public void rejectsMethodWithMultipleVerbAnnotations() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.BadApi", + "package com.example;\n" + + "import com.codename1.annotations.rest.*;\n" + + "import com.codename1.io.rest.Response;\n" + + "import com.codename1.util.OnComplete;\n" + + "@RestClient\n" + + "public interface BadApi {\n" + + " @GET(\"/a\")\n" + + " @POST(\"/a\")\n" + + " void mixedVerbs(OnComplete> cb);\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on multi-verb method", ctx.hasErrors()); + } + + @Test + public void rejectsRestClientOnNonInterface() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.rest.RestClient;\n" + + "@RestClient public class Bad {}\n"), + classes, Arrays.asList(testClassesDir())); + + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on @RestClient applied to a class", ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + RestClientAnnotationProcessor proc = new RestClientAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + /// Re-runs the processor's source-generation step via reflection so we can + /// string-assert on the generated source body. The processor itself + /// compiles and discards the sources at finish() time. + private String generateImplSourceForFixture(File classesDir) throws Exception { + return invokeGenerator(classesDir, "generateImplSource"); + } + + private String generateBootstrapSourceForFixture(File classesDir) throws Exception { + return invokeGenerator(classesDir, "generateBootstrapSource"); + } + + private String invokeGenerator(File classesDir, String which) throws Exception { + // Rebuild the processor's accumulator by running start() + processClass(). + Map index = ClassScanner.scan(classesDir); + RestClientAnnotationProcessor proc = new RestClientAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + java.lang.reflect.Field f = RestClientAnnotationProcessor.class.getDeclaredField("accepted"); + f.setAccessible(true); + java.util.TreeMap accepted = (java.util.TreeMap) f.get(proc); + if ("generateImplSource".equals(which)) { + Object petApi = accepted.values().iterator().next(); + java.lang.reflect.Method m = RestClientAnnotationProcessor.class + .getDeclaredMethod("generateImplSource", + Class.forName("com.codename1.maven.processors.RestClientAnnotationProcessor$RestApi")); + m.setAccessible(true); + return (String) m.invoke(null, petApi); + } + java.lang.reflect.Method m = RestClientAnnotationProcessor.class + .getDeclaredMethod("generateBootstrapSource", Iterable.class); + m.setAccessible(true); + return (String) m.invoke(null, accepted.values()); + } + + private static File testClassesDir() throws Exception { + URL url = RestClientAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/native-themes/android-material/theme.css b/native-themes/android-material/theme.css index 8545e7baa5..4375143313 100644 --- a/native-themes/android-material/theme.css +++ b/native-themes/android-material/theme.css @@ -71,6 +71,19 @@ tabsSafeAreaBool: true; tabsFillRowsBool: true; tabsGridBool: true; + /* Material 3 NavigationBar-style sliding indicator: a thin coloured + underline that tweens between tabs when the user changes + selection. Off by default in the framework so existing apps + don't suddenly animate; on by default in the modern Material + theme to match the spec. Duration 200ms matches Material 3. */ + tabsAnimatedIndicatorBool: true; + tabsAnimatedIndicatorDurationInt: 200; + tabsAnimatedIndicatorThicknessMm: 1; + /* Modern arc-spinner pull-to-refresh, Material 3 style. Color + comes from the TabIndicator UIID's fg (accent-tinted). */ + pullToRefreshModernBool: true; + pullToRefreshIndicatorDiameterMm: 8; + pullToRefreshIndicatorStrokeMm: "0.6"; switchThumbPaddingInt: 2; switchThumbScaleY: "1.5"; switchTrackScaleY: "0.9"; @@ -342,6 +355,11 @@ Tab.pressed { color: var(--accent-on-container-color, #21005d); background-color SelectedTab { cn1-derive: Tab; color: var(--accent-color, #6750a4); } UnselectedTab { cn1-derive: Tab; color: #49454f; } +/* TabIndicator: color reference read by Tabs.paintAnimatedIndicator() + for the sliding underline. The actual drawing is done in Java; this + UIID only contributes the foreground colour. */ +TabIndicator { color: var(--accent-color, #6750a4); background-color: transparent; padding: 0; margin: 0; } + SideNavigationPanel { background-color: #fef7ff; padding: 0; margin: 0; } SideCommand { @@ -534,6 +552,9 @@ PopupContent { SelectedTab { color: var(--accent-color-dark, #d0bcff); } UnselectedTab { color: #cac4d0; } + /* Animated indicator inherits the dark-mode accent. */ + TabIndicator { color: var(--accent-color-dark, #d0bcff); background-color: transparent; } + SideNavigationPanel { background-color: #141218; } SideCommand { color: #e6e0e9; background-color: transparent; } SideCommand.pressed { background-color: var(--accent-pressed-color-dark, #4f378b); } diff --git a/native-themes/ios-modern/theme.css b/native-themes/ios-modern/theme.css index ef299dd9b8..7069c7ae3a 100644 --- a/native-themes/ios-modern/theme.css +++ b/native-themes/ios-modern/theme.css @@ -66,6 +66,22 @@ Themes painting a flush full-width tab strip leave this true (the framework default) so the tab strip itself reserves the inset. */ tabsSafeAreaBool: false; + /* iOS 26 NavigationBar-style sliding indicator: a thin coloured + underline that tweens between tabs when the user changes + selection. Off by default in the framework (so existing apps + don't suddenly animate); on by default in the modern native + theme. Duration 200ms matches Material 3's reference. */ + tabsAnimatedIndicatorBool: true; + tabsAnimatedIndicatorDurationInt: 200; + tabsAnimatedIndicatorThicknessMm: 1; + /* Modern arc-spinner pull-to-refresh. Replaces the legacy + rotating-arrow + text stack with a thin circular arc whose sweep + grows as the user pulls, then spins continuously while the + refresh task is running. Color is pulled from the TabIndicator + UIID's fg (already accent-tinted in this theme). */ + pullToRefreshModernBool: true; + pullToRefreshIndicatorDiameterMm: 8; + pullToRefreshIndicatorStrokeMm: "0.6"; /* Route the Tab icon's styling through a separate UIID. FontImage copies the Button's bgColor/transparency into the rendered glyph image; over the cn1-pill-border selected pill that paints a @@ -322,6 +338,11 @@ UnselectedTab { cn1-derive: Tab; color: #000000; } square inside the pill. Color (light/dark) inherits from the parent Tab via Style.fgColor at icon-render time. */ TabIcon { background-color: transparent; padding: 0; margin: 0; } + +/* TabIndicator: color reference read by Tabs.paintAnimatedIndicator() + for the sliding underline. The actual drawing is done in Java; this + UIID only contributes the foreground colour. */ +TabIndicator { color: var(--accent-color, #007aff); background-color: transparent; padding: 0; margin: 0; } TabIcon.selected { background-color: transparent; } TabIcon.pressed { background-color: transparent; } @@ -563,6 +584,9 @@ PopupContent { TabIcon.selected { color: #ffffff; background-color: transparent; } TabIcon.pressed { color: #ffffff; background-color: transparent; } + /* Animated indicator inherits the dark-mode accent. */ + TabIndicator { color: var(--accent-color-dark, #0a84ff); background-color: transparent; } + SideNavigationPanel { background-color: #000000; } SideCommand { color: #ffffff; } SideCommand.pressed { background-color: #3a3a3c; } diff --git a/scripts/android/screenshots/MorphTransitionScrolledSourceTest.png b/scripts/android/screenshots/MorphTransitionScrolledSourceTest.png new file mode 100644 index 0000000000..3af95b27ce Binary files /dev/null and b/scripts/android/screenshots/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/android/screenshots/MorphTransitionSnapshotTest.png b/scripts/android/screenshots/MorphTransitionSnapshotTest.png new file mode 100644 index 0000000000..7402346250 Binary files /dev/null and b/scripts/android/screenshots/MorphTransitionSnapshotTest.png differ diff --git a/scripts/android/screenshots/MorphTransitionTest.png b/scripts/android/screenshots/MorphTransitionTest.png new file mode 100644 index 0000000000..b8cf21b3da Binary files /dev/null and b/scripts/android/screenshots/MorphTransitionTest.png differ diff --git a/scripts/android/screenshots/PullToRefreshSpinnerScreenshotTest.png b/scripts/android/screenshots/PullToRefreshSpinnerScreenshotTest.png new file mode 100644 index 0000000000..96c87d1a35 Binary files /dev/null and b/scripts/android/screenshots/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/android/screenshots/TabsAnimatedIndicatorScreenshotTest.png b/scripts/android/screenshots/TabsAnimatedIndicatorScreenshotTest.png new file mode 100644 index 0000000000..2d2e976354 Binary files /dev/null and b/scripts/android/screenshots/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/android/screenshots/TabsTheme_dark.png b/scripts/android/screenshots/TabsTheme_dark.png index bd617aada3..3debad7ed9 100644 Binary files a/scripts/android/screenshots/TabsTheme_dark.png and b/scripts/android/screenshots/TabsTheme_dark.png differ diff --git a/scripts/android/screenshots/TabsTheme_light.png b/scripts/android/screenshots/TabsTheme_light.png index e1b949a65d..173429778a 100644 Binary files a/scripts/android/screenshots/TabsTheme_light.png and b/scripts/android/screenshots/TabsTheme_light.png differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 2cd24f099d..84c20b6f91 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -100,6 +100,11 @@ private static int testTimeoutMs() { new UncoverHorizontalTransitionTest(), new FadeTransitionTest(), new FlipTransitionTest(), + new MorphTransitionTest(), + new MorphTransitionScrolledSourceTest(), + new MorphTransitionSnapshotTest(), + new TabsAnimatedIndicatorScreenshotTest(), + new PullToRefreshSpinnerScreenshotTest(), new AnimateLayoutScreenshotTest(), new AnimateHierarchyScreenshotTest(), new AnimateUnlayoutScreenshotTest(), @@ -356,6 +361,11 @@ private static boolean isJsSkippedAnimationTest(String testName) { || "UncoverHorizontalTransitionTest".equals(testName) || "FadeTransitionTest".equals(testName) || "FlipTransitionTest".equals(testName) + || "MorphTransitionTest".equals(testName) + || "MorphTransitionScrolledSourceTest".equals(testName) + || "MorphTransitionSnapshotTest".equals(testName) + || "TabsAnimatedIndicatorScreenshotTest".equals(testName) + || "PullToRefreshSpinnerScreenshotTest".equals(testName) || "AnimateLayoutScreenshotTest".equals(testName) || "AnimateHierarchyScreenshotTest".equals(testName) || "AnimateUnlayoutScreenshotTest".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionScrolledSourceTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionScrolledSourceTest.java new file mode 100644 index 0000000000..f81a4e366c --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionScrolledSourceTest.java @@ -0,0 +1,111 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.animations.MorphTransition; +import com.codename1.ui.animations.Transition; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// Regression test for the "source inside a scrolling parent" case of +/// {@link MorphTransition}. +/// +/// The source form has a long scrolling list of placeholder cards plus one +/// named "card-of-interest" that lives partway down the list. Before the +/// transition starts the source's scroll position is advanced so the named +/// card sits near the top edge of the viewport -- meaning the off-viewport +/// cards above it are clipped by the form bounds, not the parent's content +/// bounds. The destination form positions the same-named card at the top +/// of the form, full-width. +/// +/// The bug this test guards against: an earlier {@link MorphTransition} +/// implementation re-painted the source component live during animate() +/// using {@code paintComponent(g)}, which renders the *full* component +/// including off-viewport pixels. With a scrolled parent this caused the +/// morph to briefly show pixels the user could not see at the moment they +/// tapped. The fix is to capture the source as a clipped Image at +/// initTransition() and tween that. Any regression that puts the source +/// back on the live-paint path produces a different grid image and the +/// screenshot diff catches it. +public class MorphTransitionScrolledSourceTest extends AbstractTransitionScreenshotTest { + + private static final String CARD_NAME = "morph-card-scrolled"; + /// How far down the long list the named card sits. Picked large enough + /// that on every supported skin the card is at least one viewport + /// below the top of the list before scrolling. + private static final int LEADING_FILLER = 12; + private static final int TRAILING_FILLER = 12; + + @Override + protected Transition createTransition(int duration) { + return MorphTransition.create(duration).morph(CARD_NAME); + } + + @Override + protected void buildSourceForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x1f4068); + cps.setFgColor(0xffffff); + + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + for (int i = 0; i < LEADING_FILLER; i++) { + list.add(buildFiller("Filler " + (i + 1), 0x2a4a78)); + } + Label card = new Label("Card"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(12, 12, 8, 8); + cs.setMargin(2, 2, 4, 4); + list.add(card); + + for (int i = 0; i < TRAILING_FILLER; i++) { + list.add(buildFiller("Filler " + (LEADING_FILLER + i + 1), 0x2a4a78)); + } + form.add(BorderLayout.CENTER, list); + + // Force a layout so the list has real bounds, then scroll so the + // named card sits near the top of the viewport. Without this, the + // scroll-out-of-view condition the test depends on doesn't occur. + form.layoutContainer(); + list.scrollComponentToVisible(card); + } + + private Label buildFiller(String text, int bgColor) { + Label l = new Label(text); + Style s = l.getAllStyles(); + s.setBgColor(bgColor); + s.setFgColor(0xffffff); + s.setBgTransparency(255); + s.setPadding(12, 12, 8, 8); + s.setMargin(2, 2, 4, 4); + return l; + } + + @Override + protected void buildDestForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x9c1d1d); + cps.setFgColor(0xffffff); + + Label card = new Label("Card (expanded)"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(24, 24, 12, 12); + cs.setMargin(0, 8, 8, 8); + + form.add(BorderLayout.NORTH, card); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionSnapshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionSnapshotTest.java new file mode 100644 index 0000000000..bdfcee41b8 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionSnapshotTest.java @@ -0,0 +1,73 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.animations.MorphTransition; +import com.codename1.ui.animations.Transition; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// Snapshot-mode variant of {@link MorphTransitionTest}. Same source / dest +/// layout, but the transition is built with +/// {@code MorphTransition.create(d).snapshotMode(true).morph(...)} so the +/// per-frame paint pulls from images captured at initTransition() rather +/// than re-painting the live source / dest components from the layered +/// pane. +/// +/// Why a separate test rather than flipping the existing one to snapshot +/// mode: the legacy live-paint path stays the framework default for +/// backwards compatibility; both paths should have their own locked +/// baseline so a regression on one doesn't get hidden under the other's +/// tolerance. +public class MorphTransitionSnapshotTest extends AbstractTransitionScreenshotTest { + + private static final String CARD_NAME = "morph-card-snapshot"; + + @Override + protected Transition createTransition(int duration) { + return MorphTransition.create(duration).snapshotMode(true).morph(CARD_NAME); + } + + @Override + protected void buildSourceForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x1f4068); + cps.setFgColor(0xffffff); + + Label card = new Label("Card"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(8, 8, 8, 8); + cs.setMargin(20, 4, 4, 20); + + Container row = new Container(new BorderLayout()); + row.add(BorderLayout.WEST, card); + form.add(BorderLayout.SOUTH, row); + } + + @Override + protected void buildDestForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x9c1d1d); + cps.setFgColor(0xffffff); + + Label card = new Label("Card (expanded)"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(20, 20, 8, 8); + + form.add(BorderLayout.NORTH, card); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionTest.java new file mode 100644 index 0000000000..22ba9fb14f --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MorphTransitionTest.java @@ -0,0 +1,72 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.animations.MorphTransition; +import com.codename1.ui.animations.Transition; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/// Baseline {@link MorphTransition} test. Source places a 6mm-square red +/// "card" tile in the bottom-left of the form; destination places the same +/// "card" expanded across the top. The morph tweens that one named +/// component between the two layouts. +/// +/// This is the simplest possible morph case -- both components are fully +/// on-screen at all times. See {@link MorphTransitionScrolledSourceTest} +/// for the source-in-scrolling-container variant that exercises the +/// clipped-capture path. +public class MorphTransitionTest extends AbstractTransitionScreenshotTest { + + private static final String CARD_NAME = "morph-card"; + + @Override + protected Transition createTransition(int duration) { + return MorphTransition.create(duration).morph(CARD_NAME); + } + + @Override + protected void buildSourceForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x1f4068); + cps.setFgColor(0xffffff); + + Label card = new Label("Card"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(8, 8, 8, 8); + cs.setMargin(20, 4, 4, 20); + + // The card lives in the SOUTH region as a compact tile. + Container row = new Container(new BorderLayout()); + row.add(BorderLayout.WEST, card); + form.add(BorderLayout.SOUTH, row); + } + + @Override + protected void buildDestForm(Form form) { + form.setLayout(new BorderLayout()); + Style cps = form.getContentPane().getAllStyles(); + cps.setBgTransparency(255); + cps.setBgColor(0x9c1d1d); + cps.setFgColor(0xffffff); + + Label card = new Label("Card (expanded)"); + card.setName(CARD_NAME); + Style cs = card.getAllStyles(); + cs.setBgColor(0xef4444); + cs.setFgColor(0xffffff); + cs.setBgTransparency(255); + cs.setPadding(20, 20, 8, 8); + + // The card lives in NORTH and stretches across the full width. + form.add(BorderLayout.NORTH, card); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PullToRefreshSpinnerScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PullToRefreshSpinnerScreenshotTest.java new file mode 100644 index 0000000000..0b39362472 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PullToRefreshSpinnerScreenshotTest.java @@ -0,0 +1,90 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; + +import java.util.Hashtable; + +/// Captures the modern pull-to-refresh arc spinner across six frames during +/// its continuous-spin phase. The painter only takes the modern path when +/// `pullToRefreshModernBool` is true, so the test layers that constant onto +/// the active theme (the same flag the new iOS / Android native themes set +/// by default). `modernSpinStartTime` reads +/// [com.codename1.ui.animations.AnimationTime] (which the harness advances +/// per frame), so each cell renders the arc at a different rotation angle. +public class PullToRefreshSpinnerScreenshotTest extends AbstractAnimationScreenshotTest { + private Form host; + private Container scrollHost; + + @Override + protected int getAnimationDurationMillis() { + // One full ~360 deg/sec sweep = 2000ms (startAngle ticks elapsed/2). + return 2000; + } + + @Override + protected void prepareCapture(int frameWidth, int frameHeight) { + super.prepareCapture(frameWidth, frameHeight); + + // Overlay the modern theme constants so DefaultLookAndFeel + // picks the arc-spinner path. `addThemeProps` keeps the rest + // of the theme untouched and matches what the iOS Modern / + // Android Material native themes ship by default. + Hashtable overlay = new Hashtable(); + overlay.put("@pullToRefreshModernBool", "true"); + UIManager.getInstance().addThemeProps(overlay); + + host = new Form("PullToRefresh", new BorderLayout()); + host.setWidth(frameWidth); + host.setHeight(frameHeight); + host.setVisible(true); + Style cps = host.getContentPane().getAllStyles(); + cps.setBgColor(0xf0f4f8); + cps.setBgTransparency(255); + + scrollHost = new Container(BoxLayout.y()); + scrollHost.setScrollableY(true); + scrollHost.addPullToRefresh(new Runnable() { + @Override + public void run() { + // never invoked by the test + } + }); + for (int i = 0; i < 12; i++) { + scrollHost.add(new Label("Row " + i)); + } + host.add(BorderLayout.CENTER, scrollHost); + host.layoutContainer(); + + // Pin the container in the "task running" state so the painter + // draws the continuous-spin arc. + scrollHost.putClientProperty("$pullToRelease", "updating"); + } + + @Override + protected void renderFrame(Graphics g, int width, int height, double progress, int frameIndex) { + // The host form's paint chain renders the modern arc spinner + // because the container carries `$pullToRelease=updating`. A + // second explicit drawPullToRefresh call would stack a duplicate + // indicator at a different y (the painter offsets by the host's + // title-bar height during the regular paint and skips that offset + // when called directly), so leave the form to render it once. + host.paintComponent(g, true); + } + + @Override + protected void finishCapture() { + // Reset the theme so the next test in the suite isn't carrying + // the modern flag. + UIManager.getInstance().refreshTheme(); + host = null; + scrollHost = null; + super.finishCapture(); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsAnimatedIndicatorScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsAnimatedIndicatorScreenshotTest.java new file mode 100644 index 0000000000..b9cd83af3f --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsAnimatedIndicatorScreenshotTest.java @@ -0,0 +1,59 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Tabs; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; + +/// Walks the animated tabs indicator from tab 0 to tab 2 across six frames so +/// the under-line slide is captured deterministically. Reads +/// [com.codename1.ui.animations.AnimationTime] (set per-frame by the harness) +/// to interpolate `indicatorFromX/W -> indicatorToX/W` via the Motion the +/// Tabs class started in `prepareCapture`. +public class TabsAnimatedIndicatorScreenshotTest extends AbstractAnimationScreenshotTest { + private Form host; + private Tabs tabs; + + @Override + protected int getAnimationDurationMillis() { + return 200; + } + + @Override + protected void prepareCapture(int frameWidth, int frameHeight) { + super.prepareCapture(frameWidth, frameHeight); + host = new Form("Tabs Indicator", new BorderLayout()); + host.setWidth(frameWidth); + host.setHeight(frameHeight); + host.setVisible(true); + Style cps = host.getContentPane().getAllStyles(); + cps.setBgColor(0xf0f4f8); + cps.setBgTransparency(255); + + tabs = new Tabs(); + tabs.setAnimatedIndicator(true); + tabs.addTab("Home", new Button("Home content")); + tabs.addTab("Search", new Button("Search content")); + tabs.addTab("Profile", new Button("Profile content")); + host.add(BorderLayout.CENTER, tabs); + host.layoutContainer(); + + // Kick off the indicator slide -- the Motion this starts reads + // AnimationTime which the harness advances per frame. + tabs.setSelectedIndex(2, false); + } + + @Override + protected void renderFrame(Graphics g, int width, int height, double progress, int frameIndex) { + host.paintComponent(g, true); + } + + @Override + protected void finishCapture() { + host = null; + tabs = null; + super.finishCapture(); + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index d6f730b088..6d42775fb0 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -22,7 +22,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/build-and-run.md` — Local vs cloud builds, JDK matrix, Maven goals, `codenameone_settings.properties`, running the simulator, building for iOS/Android/Web, automated (Enterprise) cloud builds in CI. - `references/build-hints.md` — Curated index of `codename1.arg.*` build hints (iOS, Android, push, web). -- `references/java-api-subset.md` — How to inspect the supported Java API subset, IO (`Storage`, `FileSystemStorage`), networking (`ConnectionRequest`, `Rest`), concurrency, dates, SQLite. **Read this whenever the compliance check fails or when you reach for a `java.*` API.** +- `references/java-api-subset.md` — How to inspect the supported Java API subset, IO (`Storage`, `FileSystemStorage`), networking (`ConnectionRequest`, `Rest`), OAuth/OpenID Connect (`OidcClient`), WebSockets (cn1lib), concurrency, dates, SQLite. **Read this whenever the compliance check fails or when you reach for a `java.*` API.** - `references/ui-components.md` — Form, Toolbar, Container layouts (Border/Box/Flow/Grid/Layered), common components, navigation, dialogs. - `references/css.md` — CSS capabilities and (important) **limitations**. Selectors, supported properties, 9-patch borders, theme constants. - `references/swing-comparison.md` — Mapping Swing concepts and code to Codename One. Read this when porting Swing code. diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md index b4c3c3592e..a9da005ba8 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-and-run.md @@ -40,12 +40,29 @@ mvn -pl common cn1:run # Debug from your IDE (attaches to JDWP). mvn -pl common cn1:debug +# Hot reload of edited Java code (HotSwap, not full app restart) — see +# "Hot reload" below. Pick the mode from the simulator's Hot Reload menu. + # Run the CN1 test runner (NOT surefire). Runs inside the simulator JVM. mvn -pl common cn1:test # Compile CSS into theme.res without running the app. Process-resources phase. mvn -pl common compile +# Generate stubs for any com.codename1.system.NativeInterface in common/ +# (one per platform under android/, ios/, javase/, javascript/). +mvn -pl common cn1:generate-native-interfaces + +# Generate a typed REST client from an OpenAPI 3.x spec. Writes +# `@Mapped` records (Java 17+) or classes (Java 8) per schema plus one +# `@RestClient`-annotated interface per OpenAPI tag into common/src/main/java +# at . The annotation processors run during the next compile +# and emit the wire impls into common/target/generated-sources -- the +# project source stays clean. +mvn -pl common cn1:generate-openapi \ + -Dcn1.openapi.spec=petstore.json \ + -Dcn1.openapi.basePackage=com.example.petstore + # --- Cloud builds (need a Codename One account; some need Enterprise tier) --- # Native iOS app (.ipa). Cloud-built. @@ -66,6 +83,24 @@ mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=windo mvn -pl javase package -Dcodename1.platform=javase -Dcodename1.buildTarget=linux-desktop ``` +## Hot reload — Java edits without restarting the simulator + +The simulator (`cn1:run` / `cn1:debug`) supports three reload modes, picked from the **Hot Reload** menu in the simulator's window (the setting persists per-project in Java preferences): + +| Mode | What it does | When to use | +| --- | --- | --- | +| **Disabled** (default) | No reload — restart `cn1:run` to pick up Java edits. CSS still live-reloads regardless. | Production-style runs. | +| **Reload Simulator** | A file watcher (`SourceChangeWatcher`) recompiles changed `.java` files in `common/src/main/java/` via the IDE's incremental compiler and triggers a simulator restart. The simulator keeps the same JVM but rebuilds the form stack. | The most reliable mode — works on any edit (new class, signature change, new field). | +| **Reload Current Form** | Requires the **HotswapAgent** JVM agent (set up via `cn1:debug` + an IDE that pushes class file redefinitions through JDWP) **plus** CodeRAD wiring on your forms. When a `.java` file is recompiled, the simulator calls `FormController.tryCloneAndReplaceCurrentForm()` and re-shows the current form on the patched class. | Fastest iteration for UI tweaks inside a single form — preserves global state and the navigation stack, only the visible form re-renders. Method-body edits work; adding fields or methods falls back to a full restart. | + +Behind the scenes: + +- **CSS reload** is always on. Edits to `common/src/main/css/theme.css` are watched and `theme.res` is regenerated + re-injected into the running simulator without restarting the JVM. This works in every mode. +- **Java reload (mode 2)** is driven by the standard JVM HotSwap protocol (`-agentlib:jdwp=...,redefinitions=true`) plus [HotswapAgent](https://github.com/HotswapProjects/HotswapAgent) for the deeper redefinitions (added/removed methods, new classes). The IDE compiles the `.java` to a `.class` and JDWP-pushes it; the simulator notices via a system property and re-clones the form. +- The watcher only sees files written by the IDE — running `mvn compile` from a separate shell doesn't trigger reload because the IDE's incremental compiler is what writes the `.class` to `target/classes/`. + +Method-body edits feel instantaneous; structural edits cost a form rebuild but no JVM restart. + ## Tests in CI/CD For **logic, UI, and screenshot tests**, run `mvn -pl common cn1:test` (the CN1 test runner). It executes inside a local JVM via the simulator, so CI/CD does not need a Codename One account, a build server, or platform tooling — any GitHub Actions runner with a JDK 17+ can run it. This is the right loop for fast feedback. diff --git a/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md b/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md index 7077f98bcf..f897d20523 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md +++ b/scripts/initializr/common/src/main/resources/skill/references/java-api-subset.md @@ -74,6 +74,7 @@ When in doubt, list the jar (top of this file) and search the jar's class listin | `java.util.logging.*` | **Not supported.** | `com.codename1.io.Log` (`Log.p(message)`, `Log.e(throwable)`, `Log.sendLog()` to upload). | | `java.util.regex` | **Not supported.** | For simple matching use `String.matches(...)` / `String.split(...)` / `String.replace(...)` — these are present and use a simplified pattern syntax under the hood. For real PCRE-style regex, look for a regex cn1lib or write the matcher by hand. | | `java.lang.invoke.*`, `java.lang.module.*` | **Forbidden.** | Don't generate code at runtime. | +| `java.util.concurrent.atomic.AtomicLongArray`, `AtomicIntegerArray`, `AtomicReferenceArray`, `LongAdder`, `LongAccumulator` | **Forbidden** in the runtime subset. `AtomicReference`, `AtomicInteger`, `AtomicLong`, `AtomicBoolean` are all supported. | For atomic arrays, hand-roll a `synchronized` wrapper around a regular `int[]` / `long[]`. For high-throughput counters where `LongAdder`'s contention-splitting matters, use `synchronized` + a long field; the contention pattern that motivates `LongAdder` is rarely a bottleneck on mobile. | ## Resource files are a flat namespace @@ -329,6 +330,93 @@ Rest.post("https://api.example.com/items") Use `Rest` for ~95% of REST API work. The `fetchAs*` methods marshal into `byte[]`, `String`, `Map`, `JSONArray`, or `PropertyBusinessObject` and always invoke the callback on the EDT. +For **top-level JSON arrays** (`[{...}, {...}]`) use `fetchAsJsonList(OnComplete>)` -- it parses through the same `JSONParser` (which internally wraps top-level arrays under a synthetic `"root"` key) and hands you the unwrapped `List` directly. + +### Typed responses — `fetchAsMapped` / `fetchAsMappedList` + +Once a DTO is `@Mapped`-annotated (see *Build-time POJO binding* below), use `fetchAsMapped(Class, callback)` instead of `fetchAsJsonMap` — the callback receives the typed object directly: + +```java +@Mapped public class Pet { + @JsonProperty("id") public long id; + @JsonProperty("name") public String name; + @JsonProperty("photoUrls") public List photoUrls; +} + +Rest.get(baseUrl + "/pet/" + petId) + .header("Authorization", "Bearer " + token) + .acceptJson() + .fetchAsMapped(Pet.class, response -> { + Pet pet = response.getResponseData(); // already typed + renderPet(pet); + }); + +// List variant for endpoints returning a top-level JSON array of DTOs: +Rest.get(baseUrl + "/albums") + .acceptJson() + .fetchAsMappedList(Album.class, response -> { + List albums = response.getResponseData(); + }); +``` + +If no `Mapper` is registered for `Class` (typical cause: the class isn't `@Mapped`, or the `process-annotations` Mojo didn't run), the callback completes with `null` data and a normal response code — inspect `response.getResponseCode()` to differentiate "server error" from "no mapper registered". + +For **bulk REST clients** (an existing OpenAPI 3.x spec, dozens of endpoints), use the `cn1:generate-openapi` Maven goal — it emits `@Mapped` records / classes per schema and one `@RestClient`-annotated interface per OpenAPI tag into `common/src/main/java`. The annotation processors run on the next compile and write the wire impls into generated-sources, so the implementation isn't part of the project source. Call sites instantiate via the generated `Api.of(baseUrl)` static factory. + +### Writing JSON — `JSONWriter` + +For ad-hoc request bodies use `com.codename1.io.JSONWriter`. Two access modes: + +```java +// One-shot: pass any Map / List / String / Number / Boolean / null tree +String json = JSONWriter.toJson(Map.of("name", "ada", "values", List.of(1, 2, 3))); + +// Fluent builder for tiny request bodies (faster to read than a Map literal): +String body = JSONWriter.object() + .put("email", email) + .put("password", password) + .toJson(); + +// Streaming variants for large outputs: +JSONWriter.toJson(value, writer); +JSONWriter.toJson(value, outputStream); // UTF-8 +``` + +For *typed* DTO serialization (POJOs annotated with `@Mapped`), use `com.codename1.mapping.Mappers#toJson` from the build-time binding framework instead. `JSONWriter` is for untyped maps/lists. + +### Authenticated image URLs — `URLImage.RequestDecorator` + +`URLImage.createToStorage(placeholder, key, url, adapter)` is the standard CN1 idiom for lazy network-loaded images cached to `Storage`. When the URL is behind a bearer token (or any other custom header), use the decorator hook: + +```java +// Global: one line at app boot, applies to every URLImage from then on +URLImage.setDefaultBearerToken(Preferences.get("auth.token", null)); + +// Or the explicit form: +URLImage.setDefaultRequestDecorator(req -> + req.addRequestHeader("Authorization", "Bearer " + token)); + +// Per-image: pass a decorator at construction time. Runs *after* the +// global default so it can override. +URLImage.createToStorage(placeholder, cacheKey, url, + URLImage.RESIZE_SCALE_TO_FILL, + req -> req.addRequestHeader("X-API-Version", "2")); +``` + +For one-off image fetches without `URLImage`'s caching (e.g. preloading neighbours in an asset viewer where you stream bytes straight into `Storage`), drop to a raw `ConnectionRequest` with `readResponse(InputStream)` overridden: + +```java +ConnectionRequest req = new ConnectionRequest(url) { + @Override protected void readResponse(InputStream in) throws IOException { + try (OutputStream os = Storage.getInstance().createOutputStream(cacheKey)) { + Util.copy(in, os); + } + } +}; +req.addRequestHeader("Authorization", "Bearer " + token); +NetworkManager.getInstance().addToQueue(req); +``` + ### What about `HttpClient` / `URLConnection` / `OkHttp`? `java.net.http.HttpClient` and standard `java.net.URLConnection` aren't in the subset. OkHttp pulls in Android-only deps. **Use `ConnectionRequest` or `Rest` instead.** @@ -347,6 +435,32 @@ try (InputStream is = conn.getInputStream()) { This is a portability shim, not a feature-complete replacement. Prefer `Rest` for new code. +### OAuth 2.0 / OpenID Connect — `OidcClient` + +For modern sign-in flows (Google, Apple, Microsoft Entra, Auth0, Okta, Keycloak — anything that publishes `.well-known/openid-configuration`), use `com.codename1.io.oidc.OidcClient`. It drives the **system browser** — `ASWebAuthenticationSession` on iOS, Custom Tabs on Android, the user's default browser on desktop/web — which is the only flow Apple/Google/Microsoft/Facebook still accept. PKCE S256 is mandatory, refresh-token rotation is first-class, ID-token claims are surfaced via `getClaim(String)`, and tokens persist through a pluggable `TokenStore`. + +```java +import com.codename1.io.oidc.OidcClient; +import com.codename1.io.oidc.OidcTokens; + +OidcClient.discover("https://accounts.google.com").ready(client -> { + client.setClientId("YOUR_CLIENT_ID") + .setRedirectUri("com.example.app:/oauth2redirect") + .setScopes("openid", "email", "profile"); + client.authorize().ready((OidcTokens tokens) -> { + String access = tokens.getAccessToken(); + String email = (String) tokens.getClaim("email"); + Preferences.set("auth.token", access); + }); +}); +``` + +The pre-existing `com.codename1.io.Oauth2` and `com.codename1.social.Login` classes are kept for source compatibility but **deprecated** — they use an in-app `BrowserComponent` WebView which the major IdPs now reject. New code should use `OidcClient`. + +Companion classes in `com.codename1.io.oidc`: `OidcConfiguration` (endpoints + supported scopes), `OidcTokens` (access / id / refresh tokens + claim accessors), `PkceChallenge`, `SystemBrowser` (the per-platform browser driver), `TokenStore` (defaults to `Preferences`), `OidcException`. + +For raw HTTP server-push (Server-Sent Events / long-poll), use `ConnectionRequest` with `readResponse(InputStream)` overridden to consume the stream incrementally — the same pattern as the authenticated-image loader above. + ### TLS, redirects, gzip `ConnectionRequest` handles HTTPS, gzip decompression, and HTTP redirects automatically. Override `shouldStop()`, `handleErrorResponseCode()`, or `postResponse()` for custom behavior. For self-signed certs in dev, see `ConnectionRequest.setSslCertificates(...)` — only enable in development builds. diff --git a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md index 6fa067a845..775489ff22 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md +++ b/scripts/initializr/common/src/main/resources/skill/references/native-interfaces.md @@ -32,7 +32,8 @@ public interface GpsBridge extends NativeInterface { Constraints on the interface: - Must extend `com.codename1.system.NativeInterface`. -- All method parameters and return values must be **primitives**, `String`, `byte[]`, or `PeerComponent` (for native UI views). No arbitrary Java objects, no collections, no `Object`. This is because the bridge must marshal across language boundaries. +- All method parameters and return values must be one of: **primitives** (`int`, `long`, `double`, `float`, `boolean`, `char`, `byte`, `short`), `String`, **primitive arrays** (`byte[]`, `int[]`, `long[]`, `double[]`, `float[]`, `boolean[]`, `char[]`, `short[]`), `String[]`, or `PeerComponent` (for native UI views). No arbitrary Java objects, no collections, no `Object`. This is because the bridge must marshal across language boundaries. +- **iOS array-marshalling caveat**: the iOS bridge maps **every** Java array (regardless of element type) to `NSData*` on the Objective-C side. That means an `int[]` parameter arrives as a byte buffer the native code must read by hand; a `String[]` arrives serialized as bytes the native code must deserialize itself. Plain `byte[]` is the lossless case; `int[]` / `long[]` / `double[]` are usable for fixed-format payloads (typically by treating them as little-endian byte streams); `String[]` is best avoided on iOS until the per-component-type marshalling lands. Per-platform notes for Android (JNI-style direct arrays) and JavaScript (JS arrays) are different — those marshal element-wise as you'd expect. - Callbacks from native code back to Java go through a *separate* mechanism — see *Callbacks* below. `NativeInterface` itself exposes a built-in `isSupported()` method (every native interface inherits it). Implementations should return `true` if the platform can serve the calls, `false` otherwise — callers branch on `bridge.isSupported()` before invoking real methods. diff --git a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md index 51255a242d..fe26d44a7f 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/ui-components.md +++ b/scripts/initializr/common/src/main/resources/skill/references/ui-components.md @@ -64,6 +64,19 @@ form.add(BorderLayout.CENTER, col); **Note on `ComboBox`**: It exists but is **not recommended** in CN1. The dropdown rendering is awkward on touch screens and behaves inconsistently across platforms. Use `Picker` (set `pickerType` to `Display.PICKER_TYPE_STRINGS` for a string-list picker) — it opens a native sheet on iOS, a Material dialog on Android, and a normal popup in the simulator. `ComboBox` is kept only for legacy ports of Swing apps. +### Package locations — don't trust autocomplete to find these + +A few components live in package paths that don't match where you'd guess from the type name. Importing from the wrong package gives `cannot find symbol` and the IDE will helpfully offer to import the (deprecated or non-existent) sibling. + +| Component | Package | +| --- | --- | +| `Label`, `Button`, `TextField`, `TextArea`, `Form`, `Container`, `TextComponent`, `Dialog`, `Tabs`, `CheckBox`, `RadioButton`, `Slider`, `List` | `com.codename1.ui` | +| `SpanLabel`, `SpanButton`, `MultiButton`, `MultiList`, `Switch`, `ScaleImageButton`, `ScaleImageLabel`, `ToastBar`, `InfiniteProgress`, `ImageViewer`, `FloatingActionButton`, `StickyHeaderContainer`, `Accordion` | `com.codename1.components` | +| `Picker` | `com.codename1.ui.spinner` | +| `InfiniteContainer` | `com.codename1.ui` (despite being a "component") | + +When in doubt, `find CodenameOne/src -name ".java"` from the framework checkout — much faster than guessing which sub-package is current. + ## Container is structural — don't style its UIID `Container` is the layout glue between visible components, **not** a styled component itself. The default `Container` UIID must remain transparent with **0 padding / 0 margin / no border**. @@ -443,6 +456,20 @@ In most CN1 codebases the **default transition** is set globally via theme const `CommonTransitions` exposes slide / fade / cover / uncover / dialog / empty transitions. `MorphTransition.create(durationMs).morph(sourceCmp, targetCmp)` animates a specific source component into a specific destination component across forms — great for "tap a card to expand it into the full screen". +#### `MorphTransition.snapshotMode(boolean)` + +Opt into snapshot-mode when the live-paint morph leaks off-viewport children (source inside a scrolling parent) or renders dynamic content (video, `BrowserComponent`): + +```java +MorphTransition morph = MorphTransition.create(300).snapshotMode(true).morph("card"); +nextForm.setTransitionInAnimator(morph); +nextForm.show(); +``` + +#### Tabs animated indicator and modern pull-to-refresh + +`Tabs` has a sliding underline indicator and `addPullToRefresh` has an arc-spinner — both on by default in the modern iOS / Android themes, so apps inherit them with no extra setup. Override via `Tabs.setAnimatedIndicator(boolean)` or the `tabsAnimatedIndicatorBool` / `pullToRefreshModernBool` theme constants when needed. + ### Ongoing per-component animation: `Component.animate()` + `registerAnimated` Override `animate()` on a Component, return `true` while the animation should keep firing, and register with the form: diff --git a/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png b/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png new file mode 100644 index 0000000000..d311c56d2a Binary files /dev/null and b/scripts/ios/screenshots-metal/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/ios/screenshots-metal/MorphTransitionSnapshotTest.png b/scripts/ios/screenshots-metal/MorphTransitionSnapshotTest.png new file mode 100644 index 0000000000..ee5068c443 Binary files /dev/null and b/scripts/ios/screenshots-metal/MorphTransitionSnapshotTest.png differ diff --git a/scripts/ios/screenshots-metal/MorphTransitionTest.png b/scripts/ios/screenshots-metal/MorphTransitionTest.png new file mode 100644 index 0000000000..df9fb06fa3 Binary files /dev/null and b/scripts/ios/screenshots-metal/MorphTransitionTest.png differ diff --git a/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png b/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png new file mode 100644 index 0000000000..933046c7a2 Binary files /dev/null and b/scripts/ios/screenshots-metal/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png new file mode 100644 index 0000000000..d4635a8a5d Binary files /dev/null and b/scripts/ios/screenshots-metal/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/ios/screenshots/MorphTransitionScrolledSourceTest.png b/scripts/ios/screenshots/MorphTransitionScrolledSourceTest.png new file mode 100644 index 0000000000..bdad68403a Binary files /dev/null and b/scripts/ios/screenshots/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/ios/screenshots/MorphTransitionSnapshotTest.png b/scripts/ios/screenshots/MorphTransitionSnapshotTest.png new file mode 100644 index 0000000000..769ce9087c Binary files /dev/null and b/scripts/ios/screenshots/MorphTransitionSnapshotTest.png differ diff --git a/scripts/ios/screenshots/MorphTransitionTest.png b/scripts/ios/screenshots/MorphTransitionTest.png new file mode 100644 index 0000000000..3d7b5490bd Binary files /dev/null and b/scripts/ios/screenshots/MorphTransitionTest.png differ diff --git a/scripts/ios/screenshots/PullToRefreshSpinnerScreenshotTest.png b/scripts/ios/screenshots/PullToRefreshSpinnerScreenshotTest.png new file mode 100644 index 0000000000..d761307b26 Binary files /dev/null and b/scripts/ios/screenshots/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/ios/screenshots/TabsAnimatedIndicatorScreenshotTest.png b/scripts/ios/screenshots/TabsAnimatedIndicatorScreenshotTest.png new file mode 100644 index 0000000000..0acbf16f47 Binary files /dev/null and b/scripts/ios/screenshots/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/vm/JavaAPI/src/java/lang/Iterable.java b/vm/JavaAPI/src/java/lang/Iterable.java index 31883fb0b8..3349bf0e94 100644 --- a/vm/JavaAPI/src/java/lang/Iterable.java +++ b/vm/JavaAPI/src/java/lang/Iterable.java @@ -17,6 +17,7 @@ package java.lang; import java.util.Iterator; +import java.util.function.Consumer; /** * Objects of classes that implement this interface can be used within a @@ -32,4 +33,18 @@ public interface Iterable { * @return An {@code Iterator} instance. */ Iterator iterator(); + + /** + * Performs the given action for each element of the {@code Iterable} + * until all elements have been processed or the action throws an + * exception. + */ + default void forEach(Consumer action) { + if (action == null) { + throw new NullPointerException(); + } + for (T t : this) { + action.accept(t); + } + } } diff --git a/vm/JavaAPI/src/java/util/Collection.java b/vm/JavaAPI/src/java/util/Collection.java index 1297d21b50..f7944b9159 100644 --- a/vm/JavaAPI/src/java/util/Collection.java +++ b/vm/JavaAPI/src/java/util/Collection.java @@ -17,6 +17,8 @@ package java.util; +import java.util.function.Predicate; + /** * {@code Collection} is the root of the collection hierarchy. It defines operations on @@ -313,4 +315,23 @@ public interface Collection extends java.lang.Iterable { * stored in the type of the specified array. */ public T[] toArray(T[] array); + + /** + * Removes all of the elements of this collection that satisfy the given + * predicate. Returns {@code true} if any elements were removed. + */ + default boolean removeIf(Predicate filter) { + if (filter == null) { + throw new NullPointerException(); + } + boolean removed = false; + Iterator it = iterator(); + while (it.hasNext()) { + if (filter.test(it.next())) { + it.remove(); + removed = true; + } + } + return removed; + } } diff --git a/vm/JavaAPI/src/java/util/List.java b/vm/JavaAPI/src/java/util/List.java index 68e1ad9b67..045fd92988 100644 --- a/vm/JavaAPI/src/java/util/List.java +++ b/vm/JavaAPI/src/java/util/List.java @@ -17,6 +17,8 @@ package java.util; +import java.util.function.UnaryOperator; + /** * A {@code List} is a collection which maintains an ordering for its elements. Every @@ -350,4 +352,32 @@ public interface List extends Collection { * in the type of the specified array. */ public T[] toArray(T[] array); + + /** + * Replaces each element of this list with the result of applying the + * operator to that element. + */ + default void replaceAll(UnaryOperator operator) { + if (operator == null) { + throw new NullPointerException(); + } + ListIterator it = listIterator(); + while (it.hasNext()) { + it.set(operator.apply(it.next())); + } + } + + /** + * Sorts this list according to the order induced by the specified + * {@code Comparator}. A {@code null} comparator sorts by natural ordering. + */ + default void sort(Comparator c) { + Object[] a = toArray(); + Arrays.sort(a, (Comparator) c); + ListIterator it = listIterator(); + for (Object e : a) { + it.next(); + it.set((E) e); + } + } } diff --git a/vm/JavaAPI/src/java/util/Map.java b/vm/JavaAPI/src/java/util/Map.java index 7509d1acf4..a1452ebc85 100644 --- a/vm/JavaAPI/src/java/util/Map.java +++ b/vm/JavaAPI/src/java/util/Map.java @@ -17,6 +17,10 @@ package java.util; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; + /** * A {@code Map} is a data structure consisting of a set of keys and values @@ -250,4 +254,182 @@ public static interface Entry { * @return a collection of the values contained in this map. */ public Collection values(); + + // ---- Java 8 default methods. Mirror of the same set in + // ---- Ports/CLDC11/src/java/util/Map.java; kept in sync. + + /** + * Returns the value to which the specified key is mapped, or + * {@code defaultValue} if this map contains no mapping for the key. + */ + default V getOrDefault(Object key, V defaultValue) { + V v = get(key); + return v != null ? v : defaultValue; + } + + /** + * If the specified key is not already associated with a value (or is + * mapped to {@code null}) associates it with the given value and returns + * {@code null}, else returns the current value. + */ + default V putIfAbsent(K key, V value) { + V v = get(key); + if (v == null) { + v = put(key, value); + } + return v; + } + + /** + * Removes the entry for the specified key only if it is currently mapped + * to the specified value. + */ + default boolean remove(Object key, Object value) { + Object curr = get(key); + if (curr == null ? value != null : !curr.equals(value)) { + return false; + } + if (curr == null && !containsKey(key)) { + return false; + } + remove(key); + return true; + } + + /** + * Replaces the entry for the specified key only if currently mapped to + * the specified value. + */ + default boolean replace(K key, V oldValue, V newValue) { + Object curr = get(key); + if (curr == null ? oldValue != null : !curr.equals(oldValue)) { + return false; + } + if (curr == null && !containsKey(key)) { + return false; + } + put(key, newValue); + return true; + } + + /** + * Replaces the entry for the specified key only if it is currently + * mapped to some value. + */ + default V replace(K key, V value) { + V curr = get(key); + if (curr != null || containsKey(key)) { + curr = put(key, value); + } + return curr; + } + + /** + * Performs the given action for each entry in this map until all entries + * have been processed or the action throws an exception. + */ + default void forEach(BiConsumer action) { + if (action == null) { + throw new NullPointerException(); + } + for (Map.Entry entry : entrySet()) { + action.accept(entry.getKey(), entry.getValue()); + } + } + + /** + * Replaces each entry's value with the result of invoking the given + * function on that entry. + */ + default void replaceAll(BiFunction function) { + if (function == null) { + throw new NullPointerException(); + } + for (Map.Entry entry : entrySet()) { + entry.setValue(function.apply(entry.getKey(), entry.getValue())); + } + } + + /** + * If the specified key is not already associated with a value, attempts to + * compute its value using the given mapping function and enters it into + * this map unless {@code null}. + */ + default V computeIfAbsent(K key, Function mappingFunction) { + if (mappingFunction == null) { + throw new NullPointerException(); + } + V v = get(key); + if (v == null) { + V newValue = mappingFunction.apply(key); + if (newValue != null) { + put(key, newValue); + return newValue; + } + } + return v; + } + + /** + * If the value for the specified key is present and non-null, attempts to + * compute a new mapping. + */ + default V computeIfPresent(K key, + BiFunction remappingFunction) { + if (remappingFunction == null) { + throw new NullPointerException(); + } + V old = get(key); + if (old != null) { + V newValue = remappingFunction.apply(key, old); + if (newValue != null) { + put(key, newValue); + return newValue; + } + remove(key); + } + return null; + } + + /** + * Attempts to compute a mapping for the specified key and its current + * mapped value (or {@code null} if there is no current mapping). + */ + default V compute(K key, + BiFunction remappingFunction) { + if (remappingFunction == null) { + throw new NullPointerException(); + } + V old = get(key); + V newValue = remappingFunction.apply(key, old); + if (newValue == null) { + if (old != null || containsKey(key)) { + remove(key); + } + return null; + } + put(key, newValue); + return newValue; + } + + /** + * If the specified key is not already associated with a value or is + * associated with null, associates it with the given non-null value; + * otherwise replaces the associated value with the results of the + * remapping function, or removes if the result is {@code null}. + */ + default V merge(K key, V value, + BiFunction remappingFunction) { + if (remappingFunction == null || value == null) { + throw new NullPointerException(); + } + V old = get(key); + V newValue = old == null ? value : remappingFunction.apply(old, value); + if (newValue == null) { + remove(key); + } else { + put(key, newValue); + } + return newValue; + } } diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicBoolean.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicBoolean.java new file mode 100644 index 0000000000..817cca7b31 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicBoolean.java @@ -0,0 +1,71 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/** + * Codename One subset implementation of {@code AtomicBoolean}. Backed by a + * monitor on the receiver rather than the JDK's CAS hardware intrinsics -- + * the visible contract (happens-before, CAS semantics) is preserved. + */ +public class AtomicBoolean implements java.io.Serializable { + private volatile boolean value; + + public AtomicBoolean(boolean initialValue) { + value = initialValue; + } + + public AtomicBoolean() { + } + + public final boolean get() { + return value; + } + + public final void set(boolean newValue) { + value = newValue; + } + + public final void lazySet(boolean newValue) { + value = newValue; + } + + public final boolean getAndSet(boolean newValue) { + synchronized (this) { + boolean prev = value; + value = newValue; + return prev; + } + } + + public final boolean compareAndSet(boolean expect, boolean update) { + synchronized (this) { + if (value == expect) { + value = update; + return true; + } + return false; + } + } + + public final boolean weakCompareAndSet(boolean expect, boolean update) { + return compareAndSet(expect, update); + } + + @Override + public String toString() { + return String.valueOf(get()); + } +} diff --git a/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLong.java b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLong.java new file mode 100644 index 0000000000..68f05df6a8 --- /dev/null +++ b/vm/JavaAPI/src/java/util/concurrent/atomic/AtomicLong.java @@ -0,0 +1,129 @@ +/* + * 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. + */ +package java.util.concurrent.atomic; + +/** + * Codename One subset implementation of {@code AtomicLong}. Backed by a + * monitor on the receiver rather than the JDK's CAS hardware intrinsics -- + * the visible contract (happens-before, CAS semantics) is preserved. + */ +public class AtomicLong extends Number implements java.io.Serializable { + private volatile long value; + + public AtomicLong(long initialValue) { + value = initialValue; + } + + public AtomicLong() { + } + + public final long get() { + return value; + } + + public final void set(long newValue) { + value = newValue; + } + + public final void lazySet(long newValue) { + value = newValue; + } + + public final long getAndSet(long newValue) { + synchronized (this) { + long prev = value; + value = newValue; + return prev; + } + } + + public final boolean compareAndSet(long expect, long update) { + synchronized (this) { + if (value == expect) { + value = update; + return true; + } + return false; + } + } + + public final boolean weakCompareAndSet(long expect, long update) { + return compareAndSet(expect, update); + } + + public final long getAndIncrement() { + synchronized (this) { + return value++; + } + } + + public final long getAndDecrement() { + synchronized (this) { + return value--; + } + } + + public final long getAndAdd(long delta) { + synchronized (this) { + long prev = value; + value += delta; + return prev; + } + } + + public final long incrementAndGet() { + synchronized (this) { + return ++value; + } + } + + public final long decrementAndGet() { + synchronized (this) { + return --value; + } + } + + public final long addAndGet(long delta) { + synchronized (this) { + return value += delta; + } + } + + @Override + public String toString() { + return Long.toString(get()); + } + + @Override + public int intValue() { + return (int) get(); + } + + @Override + public long longValue() { + return get(); + } + + @Override + public float floatValue() { + return (float) get(); + } + + @Override + public double doubleValue() { + return (double) get(); + } +} diff --git a/vm/JavaAPI/src/java/util/function/BiFunction.java b/vm/JavaAPI/src/java/util/function/BiFunction.java new file mode 100644 index 0000000000..92c2bccaf7 --- /dev/null +++ b/vm/JavaAPI/src/java/util/function/BiFunction.java @@ -0,0 +1,5 @@ +package java.util.function; + +public interface BiFunction { + R apply(T t, U u); +}