diff --git a/.github/workflows/ai-cn1lib-android-check.yml b/.github/workflows/ai-cn1lib-android-check.yml
new file mode 100644
index 0000000000..c3299069ed
--- /dev/null
+++ b/.github/workflows/ai-cn1lib-android-check.yml
@@ -0,0 +1,134 @@
+name: AI cn1lib Android native check
+
+# Parses the Android Java sources bundled in every cn1-ai-* cn1lib
+# against android.jar so missing symbols / typos surface in PR review.
+# A full Android emulator integration run (which would also exercise the
+# bridges against fixture images via espresso) lands separately -- this
+# workflow is a fast static gate.
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [master]
+ paths:
+ - 'maven/cn1-ai-**'
+ - 'scripts/gen-ai-cn1libs.py'
+ - '.github/workflows/ai-cn1lib-android-check.yml'
+ push:
+ branches: [master]
+ paths:
+ - 'maven/cn1-ai-**'
+ - 'scripts/gen-ai-cn1libs.py'
+
+jobs:
+ android-native-check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+ - name: Install Android platform & ML Kit deps
+ # sdkmanager + `yes |` would SIGPIPE under `set -o pipefail`, so feed
+ # licence approvals via printf instead.
+ run: |
+ set -eux
+ printf 'y\n%.0s' {1..50} | sdkmanager --install "platforms;android-34" "build-tools;34.0.0"
+ mkdir -p /tmp/cn1-ai-android/probe/src/main/java /tmp/cn1-ai-android/probe/libs
+ - name: Download ML Kit / TFLite / ONNX AARs and JARs
+ # Most ML Kit deps are .aar (Android library archive); javac can't read
+ # them directly, so we unzip classes.jar out of each. Maven's
+ # dependency:copy plugin handles the download + checksum.
+ run: |
+ set -euxo pipefail
+ mkdir -p /tmp/lintprobe /tmp/lintcp
+ cat > /tmp/lintprobe/pom.xml <<'EOF'
+
+
+ 4.0.0
+ cn1.lintprobe
+ cn1-ai-android-lintprobe
+ 1.0
+ pom
+
+ google https://maven.google.com
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.6.1
+
+
+ copy-aars
+ validate
+ copy
+
+ /tmp/lintaar
+ true
+
+ com.google.mlkit text-recognition 16.0.0 aar
+ com.google.mlkit barcode-scanning 17.2.0 aar
+ com.google.mlkit face-detection 16.1.5 aar
+ com.google.mlkit image-labeling 17.0.7 aar
+ com.google.mlkit translate 17.0.3 aar
+ com.google.mlkit smart-reply 17.0.4 aar
+ com.google.mlkit language-id 17.0.6 aar
+ com.google.mlkit pose-detection 18.0.0-beta3 aar
+ com.google.mlkit segmentation-selfie 16.0.0-beta5 aar
+ com.google.mlkit vision-common 17.3.0 aar
+ com.google.mlkit common 18.11.0 aar
+ com.google.mlkit vision-interfaces 16.3.0 aar
+ com.google.android.gms play-services-tasks 18.1.0 aar
+ com.google.mlkit barcode-scanning-common 17.0.0 aar
+
+
+
+
+
+
+
+
+ EOF
+ mvn -B -f /tmp/lintprobe/pom.xml validate -q || {
+ echo "::warning::Some ML Kit dependencies failed to resolve; lint may emit false-positive errors";
+ }
+ for aar in /tmp/lintaar/*.aar; do
+ [ -f "$aar" ] || continue
+ base=$(basename "$aar" .aar)
+ mkdir -p "/tmp/lintcp/$base"
+ unzip -q -o "$aar" classes.jar -d "/tmp/lintcp/$base" 2>/dev/null || true
+ [ -f "/tmp/lintcp/$base/classes.jar" ] && \
+ mv "/tmp/lintcp/$base/classes.jar" "/tmp/lintcp/$base.jar"
+ rm -rf "/tmp/lintcp/$base"
+ done
+ ls -la /tmp/lintcp/
+
+ - name: Compile every cn1-ai-* Android source against android.jar
+ # Best-effort lint: not every transitive AAR is necessarily resolvable
+ # from public Google Maven (versions change). When a class is missing
+ # we keep going to surface every error in a single CI run rather than
+ # bailing on the first failure. Failures here gate the PR.
+ run: |
+ set -eux
+ ANDROID_JAR="$ANDROID_HOME/platforms/android-34/android.jar"
+ LINT_CP=$(find /tmp/lintcp -name '*.jar' | tr '\n' ':')
+ fail=0
+ mkdir -p /tmp/out
+ for d in maven/cn1-ai-*/android/src/main/java; do
+ sources=$(find "$d" -name '*.java')
+ [ -z "$sources" ] && continue
+ if ! javac -d /tmp/out -classpath "$ANDROID_JAR:$LINT_CP" \
+ -source 1.8 -target 1.8 \
+ -Xlint:-options \
+ $sources 2>&1 | tee /tmp/last-javac.log; then
+ echo "::error::Android Java compile failed for $d"
+ fail=1
+ fi
+ done
+ exit $fail
diff --git a/.github/workflows/ai-cn1lib-native-check.yml b/.github/workflows/ai-cn1lib-native-check.yml
new file mode 100644
index 0000000000..6b762a9123
--- /dev/null
+++ b/.github/workflows/ai-cn1lib-native-check.yml
@@ -0,0 +1,147 @@
+name: AI cn1lib iOS xcodebuild check
+
+# Per cn1lib: synthesise a tiny Xcode project that links the bundled
+# `nativeios/*.m` against the real `GoogleMLKit/*` (or TFLite / whisper)
+# pod, run `pod install`, then `xcodebuild build`. Catches API drift,
+# missing imports, and missing pod hints early -- on the same macOS
+# runners we already use for build-ios.
+
+on:
+ workflow_dispatch:
+ pull_request:
+ branches: [master]
+ paths:
+ - 'maven/cn1-ai-**'
+ - 'scripts/gen-ai-cn1libs.py'
+ - '.github/workflows/ai-cn1lib-native-check.yml'
+ push:
+ branches: [master]
+ paths:
+ - 'maven/cn1-ai-**'
+ - 'scripts/gen-ai-cn1libs.py'
+
+jobs:
+ xcodebuild-cn1libs:
+ name: xcodebuild ${{ matrix.lib }}
+ # macos-14 ships Xcode 15.4 by default but also has Xcode 16.x
+ # under /Applications/Xcode_16.X.app. xcodegen 2.45.4 emits
+ # objectVersion=77 (Xcode 16-format) projects, so we explicitly
+ # select Xcode 16 via xcode-select in a setup step below.
+ runs-on: macos-14
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { lib: cn1-ai-mlkit-text, pod: 'GoogleMLKit/TextRecognition' }
+ - { lib: cn1-ai-mlkit-barcode, pod: 'GoogleMLKit/BarcodeScanning' }
+ - { lib: cn1-ai-mlkit-face, pod: 'GoogleMLKit/FaceDetection' }
+ - { lib: cn1-ai-mlkit-labeling, pod: 'GoogleMLKit/ImageLabeling' }
+ - { lib: cn1-ai-mlkit-translate, pod: 'GoogleMLKit/Translate' }
+ - { lib: cn1-ai-mlkit-smartreply, pod: 'GoogleMLKit/SmartReply' }
+ - { lib: cn1-ai-mlkit-langid, pod: 'GoogleMLKit/LanguageID' }
+ - { lib: cn1-ai-mlkit-pose, pod: 'GoogleMLKit/PoseDetection' }
+ - { lib: cn1-ai-mlkit-segmentation, pod: 'GoogleMLKit/SegmentationSelfie' }
+ - { lib: cn1-ai-mlkit-docscan, pod: '' } # VisionKit/CoreImage only
+ - { lib: cn1-ai-tflite, pod: 'TensorFlowLiteObjC' }
+ - { lib: cn1-ai-whisper, pod: '' } # links static libwhisper.a
+ - { lib: cn1-ai-stablediffusion, pod: '' } # links Swift runner
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select Xcode 16 (xcodegen emits Xcode-16-format projects)
+ # The default xcode-select on macos-14 is Xcode 15.4; that can't
+ # read xcodegen's objectVersion=77 projects ("future project file
+ # format"). Pick the highest-version Xcode 16.* available on this
+ # runner image.
+ run: |
+ set -euxo pipefail
+ XCODE_PATH=$(ls -d /Applications/Xcode_16*.app 2>/dev/null | sort -V | tail -1)
+ if [ -z "$XCODE_PATH" ]; then
+ echo "::error::No Xcode 16.* found in /Applications"; ls /Applications/Xcode_*.app; exit 1
+ fi
+ sudo xcode-select -s "$XCODE_PATH/Contents/Developer"
+ xcodebuild -version
+
+ - name: Install xcodegen
+ # xcodegen produces a full-featured pbxproj from a YAML spec. We need
+ # a real Xcode project (not a hand-rolled minimal pbxproj) so the
+ # CocoaPods integration step can inject baseConfigurationReference
+ # entries and the xcframework -> framework extraction script phase.
+ run: brew install xcodegen
+
+ - name: Synthesise Xcode project via xcodegen
+ run: |
+ set -euxo pipefail
+ PROBE=/tmp/probe-${{ matrix.lib }}
+ mkdir -p "$PROBE/CN1AIProbe"
+ cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.h "$PROBE/CN1AIProbe/" 2>/dev/null || true
+ cp maven/${{ matrix.lib }}/ios/src/main/objectivec/*.m "$PROBE/CN1AIProbe/"
+ ls -la "$PROBE/CN1AIProbe/"
+
+ cat > "$PROBE/project.yml" <<'YAML'
+ name: CN1AIProbe
+ options:
+ bundleIdPrefix: com.codenameone.cn1ai
+ deploymentTarget:
+ iOS: "14.0"
+ targets:
+ CN1AIProbe:
+ # Static library: archives .o files without linking, so cn1libs
+ # that reference externs supplied by the real build server
+ # (libwhisper.a for whisper, cn1_sd_generate for stable
+ # diffusion, the actual ML Kit framework binaries) still
+ # compile-check successfully.
+ type: library.static
+ platform: iOS
+ sources:
+ - path: CN1AIProbe
+ settings:
+ base:
+ CLANG_ENABLE_MODULES: YES
+ CLANG_ENABLE_OBJC_ARC: YES
+ CODE_SIGNING_ALLOWED: NO
+ YAML
+ cd "$PROBE"
+ xcodegen generate --spec project.yml
+
+ - name: pod install (with integration)
+ if: ${{ matrix.pod != '' }}
+ # With a proper xcodegen-generated pbxproj, CocoaPods can integrate
+ # normally -- it injects the baseConfigurationReference, sets up the
+ # framework-extraction script phase, and produces a workspace that
+ # xcodebuild can build directly.
+ run: |
+ set -euxo pipefail
+ PROBE=/tmp/probe-${{ matrix.lib }}
+ cd "$PROBE"
+ cat > Podfile < /tmp/gen-ai.log
+ # Capture a list of changed files instead of using --exit-code/--quiet,
+ # which interacted poorly with the surrounding `set -e -o pipefail`.
+ drift=$(git -C .. status --porcelain -- 'maven/cn1-ai-*' || true)
+ if [ -n "$drift" ]; then
+ echo "::error::Checked-in cn1-ai-* tree is out of sync with scripts/gen-ai-cn1libs.py"
+ echo "$drift"
+ git -C .. --no-pager diff -- 'maven/cn1-ai-*' || true
+ exit 1
+ fi
+ # The cn1lib mojo is part of codenameone-maven-plugin and isn't
+ # in m2 from prior CI steps (those run the plugin's tests but
+ # don't install it, and -am pulls in transitive deps not yet
+ # built here -- designer, java-runtime, ports). Install the
+ # plugin + everything it transitively needs, then run package
+ # on each cn1lib's common module.
+ mvn -B -Dmaven.javadoc.skip=true \
+ -Dcn1.binaries="${CN1_BINARIES}" \
+ -Plocal-dev-javase \
+ -pl codenameone-maven-plugin -am -DskipTests install
+ mvn -B -Dmaven.javadoc.skip=true \
+ -Dcn1.binaries="${CN1_BINARIES}" \
+ -Plocal-dev-javase \
+ -pl 'cn1-ai-mlkit-text/common,cn1-ai-mlkit-barcode/common,cn1-ai-mlkit-face/common,cn1-ai-mlkit-labeling/common,cn1-ai-mlkit-translate/common,cn1-ai-mlkit-smartreply/common,cn1-ai-mlkit-langid/common,cn1-ai-mlkit-pose/common,cn1-ai-mlkit-segmentation/common,cn1-ai-mlkit-docscan/common,cn1-ai-tflite/common,cn1-ai-whisper/common,cn1-ai-stablediffusion/common' \
+ package
- name: Run SpotBugs for ports and Maven plugin
if: ${{ matrix.java-version == 8 }}
working-directory: maven
diff --git a/CodenameOne/src/com/codename1/ai/AnthropicClient.java b/CodenameOne/src/com/codename1/ai/AnthropicClient.java
new file mode 100644
index 0000000000..f01735444c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/AnthropicClient.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.util.AsyncResource;
+
+/// Anthropic /v1/messages client. Wire format differs from OpenAI in
+/// three important ways: system messages live in a top-level `system`
+/// string rather than a role; image parts use `{type:"image", source:
+/// {type:"base64", media_type, data}}`; tool calls stream argument
+/// JSON via `input_json_delta` events.
+///
+/// This is currently a scaffold -- the full request/response mapping
+/// is tracked as a follow-up. The class compiles and registers under
+/// `LlmClient.anthropic(...)` so app code using the API can be built;
+/// runtime calls throw a clear `UnsupportedOperationException`.
+class AnthropicClient extends LlmClient {
+ private final String apiKey;
+
+ AnthropicClient(String apiKey, String baseUrl) {
+ super(baseUrl);
+ this.apiKey = apiKey;
+ }
+
+ @Override
+ public String getProvider() {
+ return "anthropic";
+ }
+
+ @Override
+ public AsyncResource chat(ChatRequest req) {
+ AsyncResource r = new AsyncResource();
+ r.error(new UnsupportedOperationException(
+ "AnthropicClient is not yet implemented in this release. "
+ + "Use LlmClient.openai(...) or run the model behind an OpenAI-compatible proxy."));
+ return r;
+ }
+
+ @Override
+ public AsyncResource chatStream(ChatRequest req, StreamingListener listener) {
+ return chat(req);
+ }
+
+ @Override
+ public AsyncResource embed(EmbeddingRequest req) {
+ AsyncResource r = new AsyncResource();
+ r.error(new UnsupportedOperationException(
+ "Anthropic does not publish a first-party embeddings endpoint. "
+ + "Use a Voyage AI key via LlmClient.localOpenAiCompatible(\"https://api.voyageai.com/v1\", key, model)."));
+ return r;
+ }
+
+ String getApiKey() {
+ return apiKey;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ChatMessage.java b/CodenameOne/src/com/codename1/ai/ChatMessage.java
new file mode 100644
index 0000000000..5ddc222b77
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ChatMessage.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/// A single turn in a chat conversation. Holds a [Role], one or more
+/// [MessagePart]s, and (for assistant turns) any [ToolCall]s the model
+/// produced. Construct via the static helpers ([#user(String)],
+/// [#system(String)], etc.) for the common case, or pass parts
+/// directly for multi-modal messages.
+public final class ChatMessage {
+ private final Role role;
+ private final List parts;
+ private final List toolCalls;
+ private final String name;
+ private final String toolCallId;
+
+ public ChatMessage(Role role, List parts) {
+ this(role, parts, null, null, null);
+ }
+
+ public ChatMessage(Role role, List parts, List toolCalls,
+ String name, String toolCallId) {
+ if (role == null) {
+ throw new IllegalArgumentException("role is required");
+ }
+ this.role = role;
+ this.parts = parts == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(parts));
+ this.toolCalls = toolCalls == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(toolCalls));
+ this.name = name;
+ this.toolCallId = toolCallId;
+ }
+
+ public static ChatMessage system(String text) {
+ return single(Role.SYSTEM, new TextPart(text));
+ }
+
+ public static ChatMessage user(String text) {
+ return single(Role.USER, new TextPart(text));
+ }
+
+ public static ChatMessage assistant(String text) {
+ return single(Role.ASSISTANT, new TextPart(text));
+ }
+
+ /// Builds a USER message containing both a text and image part --
+ /// the common multi-modal pattern.
+ public static ChatMessage userWithImage(String text, ImagePart image) {
+ List parts = new ArrayList(2);
+ if (text != null && text.length() > 0) {
+ parts.add(new TextPart(text));
+ }
+ parts.add(image);
+ return new ChatMessage(Role.USER, parts);
+ }
+
+ /// Builds a TOOL message wrapping the result of a previous tool call.
+ public static ChatMessage toolResult(String toolCallId, String resultJson) {
+ return new ChatMessage(Role.TOOL,
+ Arrays.asList(new ToolResultPart(toolCallId, resultJson)),
+ null, null, toolCallId);
+ }
+
+ private static ChatMessage single(Role r, MessagePart p) {
+ List parts = new ArrayList(1);
+ parts.add(p);
+ return new ChatMessage(r, parts);
+ }
+
+ public Role getRole() {
+ return role;
+ }
+
+ public List getParts() {
+ return parts;
+ }
+
+ public List getToolCalls() {
+ return toolCalls;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getToolCallId() {
+ return toolCallId;
+ }
+
+ /// Convenience: concatenates the text of every [TextPart]. Image
+ /// and tool-result parts are skipped. Useful for `ChatView`
+ /// rendering when you don't care about multi-modal content.
+ public String getText() {
+ StringBuilder sb = new StringBuilder();
+ for (MessagePart p : parts) {
+ if (p instanceof TextPart) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(((TextPart) p).getText());
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ChatRequest.java b/CodenameOne/src/com/codename1/ai/ChatRequest.java
new file mode 100644
index 0000000000..d6458e5a32
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ChatRequest.java
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/// The full request to [LlmClient#chat(ChatRequest)] /
+/// [LlmClient#chatStream(ChatRequest, StreamingListener)]. Built via
+/// [#builder()]; immutable once constructed so the same request can be
+/// re-used across retries.
+///
+/// Numeric tuning fields are boxed so a `null` means "don't send" --
+/// the provider's own default is used instead of one we picked.
+public final class ChatRequest {
+ private final String model;
+ private final List messages;
+ private final Float temperature;
+ private final Integer maxTokens;
+ private final Float topP;
+ private final List stopSequences;
+ private final Long seed;
+ private final ResponseFormat responseFormat;
+ private final List tools;
+ private final ToolChoice toolChoice;
+ private final Map metadata;
+ private final SafetyFilter safetyFilter;
+
+ private ChatRequest(Builder b) {
+ this.model = b.model;
+ this.messages = Collections.unmodifiableList(new ArrayList(b.messages));
+ this.temperature = b.temperature;
+ this.maxTokens = b.maxTokens;
+ this.topP = b.topP;
+ this.stopSequences = b.stopSequences == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(b.stopSequences));
+ this.seed = b.seed;
+ this.responseFormat = b.responseFormat;
+ this.tools = b.tools == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(b.tools));
+ this.toolChoice = b.toolChoice;
+ this.metadata = b.metadata == null ? Collections.emptyMap()
+ : Collections.unmodifiableMap(new HashMap(b.metadata));
+ this.safetyFilter = b.safetyFilter;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public List getMessages() {
+ return messages;
+ }
+
+ public Float getTemperature() {
+ return temperature;
+ }
+
+ public Integer getMaxTokens() {
+ return maxTokens;
+ }
+
+ public Float getTopP() {
+ return topP;
+ }
+
+ public List getStopSequences() {
+ return stopSequences;
+ }
+
+ public Long getSeed() {
+ return seed;
+ }
+
+ public ResponseFormat getResponseFormat() {
+ return responseFormat;
+ }
+
+ public List getTools() {
+ return tools;
+ }
+
+ public ToolChoice getToolChoice() {
+ return toolChoice;
+ }
+
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ public SafetyFilter getSafetyFilter() {
+ return safetyFilter;
+ }
+
+ /// Returns a builder pre-populated with the values of this request.
+ /// Useful for replaying a request with one field changed.
+ public Builder toBuilder() {
+ Builder b = new Builder();
+ b.model = model;
+ b.messages = new ArrayList(messages);
+ b.temperature = temperature;
+ b.maxTokens = maxTokens;
+ b.topP = topP;
+ b.stopSequences = new ArrayList(stopSequences);
+ b.seed = seed;
+ b.responseFormat = responseFormat;
+ b.tools = new ArrayList(tools);
+ b.toolChoice = toolChoice;
+ b.metadata = new HashMap(metadata);
+ b.safetyFilter = safetyFilter;
+ return b;
+ }
+
+ public static final class Builder {
+ private String model;
+ private List messages = new ArrayList();
+ private Float temperature;
+ private Integer maxTokens;
+ private Float topP;
+ private List stopSequences;
+ private Long seed;
+ private ResponseFormat responseFormat;
+ private List tools;
+ private ToolChoice toolChoice;
+ private Map metadata;
+ private SafetyFilter safetyFilter;
+
+ Builder() {
+ }
+
+ public Builder model(String model) {
+ this.model = model;
+ return this;
+ }
+
+ public Builder messages(List messages) {
+ this.messages = messages == null ? new ArrayList()
+ : new ArrayList(messages);
+ return this;
+ }
+
+ public Builder addMessage(ChatMessage m) {
+ this.messages.add(m);
+ return this;
+ }
+
+ public Builder temperature(Float t) {
+ this.temperature = t;
+ return this;
+ }
+
+ public Builder maxTokens(Integer n) {
+ this.maxTokens = n;
+ return this;
+ }
+
+ public Builder topP(Float p) {
+ this.topP = p;
+ return this;
+ }
+
+ public Builder stopSequences(List stops) {
+ this.stopSequences = stops;
+ return this;
+ }
+
+ public Builder seed(Long seed) {
+ this.seed = seed;
+ return this;
+ }
+
+ public Builder responseFormat(ResponseFormat f) {
+ this.responseFormat = f;
+ return this;
+ }
+
+ public Builder tools(List tools) {
+ this.tools = tools;
+ return this;
+ }
+
+ public Builder toolChoice(ToolChoice choice) {
+ this.toolChoice = choice;
+ return this;
+ }
+
+ public Builder metadata(Map meta) {
+ this.metadata = meta;
+ return this;
+ }
+
+ public Builder safetyFilter(SafetyFilter f) {
+ this.safetyFilter = f;
+ return this;
+ }
+
+ public ChatRequest build() {
+ if (messages.isEmpty()) {
+ throw new IllegalStateException("at least one message is required");
+ }
+ return new ChatRequest(this);
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ChatResponse.java b/CodenameOne/src/com/codename1/ai/ChatResponse.java
new file mode 100644
index 0000000000..53f08263b0
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ChatResponse.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/// The terminal response from a chat call. For streaming requests, the
+/// `ChatResponse` carries the *aggregated* final assistant message --
+/// the individual deltas were delivered through [StreamingListener]
+/// before this object was produced.
+///
+/// `finishReason` is one of: `"stop"`, `"length"`, `"tool_calls"`,
+/// `"content_filter"`, `"error"` (normalized across providers).
+public final class ChatResponse {
+ private final ChatMessage assistantMessage;
+ private final List toolCalls;
+ private final String finishReason;
+ private final Usage usage;
+ private final String modelUsed;
+
+ public ChatResponse(ChatMessage assistantMessage, List toolCalls,
+ String finishReason, Usage usage, String modelUsed) {
+ this.assistantMessage = assistantMessage;
+ this.toolCalls = toolCalls == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(toolCalls));
+ this.finishReason = finishReason;
+ this.usage = usage;
+ this.modelUsed = modelUsed;
+ }
+
+ public ChatMessage getAssistantMessage() {
+ return assistantMessage;
+ }
+
+ public List getToolCalls() {
+ return toolCalls;
+ }
+
+ /// Convenience: the assembled assistant text. Equivalent to
+ /// `getAssistantMessage().getText()` when there is one.
+ public String getText() {
+ return assistantMessage == null ? "" : assistantMessage.getText();
+ }
+
+ public String getFinishReason() {
+ return finishReason;
+ }
+
+ public Usage getUsage() {
+ return usage;
+ }
+
+ public String getModelUsed() {
+ return modelUsed;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ConversationStore.java b/CodenameOne/src/com/codename1/ai/ConversationStore.java
new file mode 100644
index 0000000000..a3b72d7191
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ConversationStore.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.io.JSONParser;
+import com.codename1.io.Storage;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/// JSON-backed persistent conversation history.
+///
+/// Stores [ChatMessage] lists in [Storage] under a caller-chosen key
+/// so apps can rehydrate a `ChatView` after process restart.
+/// Multimodal parts ([ImagePart], [ToolResultPart]) are serialized
+/// to a lossy text fallback -- image data is not round-tripped (apps
+/// that need full multimodal persistence should encode the bytes
+/// themselves and keep them in [com.codename1.io.FileSystemStorage]).
+public final class ConversationStore {
+ private static final String KIND_TEXT = "t";
+ private static final String KIND_TOOL_RESULT = "tr";
+
+ private final String storageKey;
+
+ public ConversationStore(String storageKey) {
+ if (storageKey == null || storageKey.length() == 0) {
+ throw new IllegalArgumentException("storageKey is required");
+ }
+ this.storageKey = storageKey;
+ }
+
+ public void save(List messages) throws IOException {
+ List serialized = new ArrayList(messages == null ? 0 : messages.size());
+ if (messages != null) {
+ for (ChatMessage m : messages) {
+ Map jm = new HashMap();
+ jm.put("role", m.getRole().name());
+ List parts = new ArrayList(m.getParts().size());
+ for (MessagePart p : m.getParts()) {
+ Map jp = new HashMap();
+ if (p instanceof TextPart) {
+ jp.put("kind", KIND_TEXT);
+ jp.put("text", ((TextPart) p).getText());
+ } else if (p instanceof ToolResultPart) {
+ ToolResultPart trp = (ToolResultPart) p;
+ jp.put("kind", KIND_TOOL_RESULT);
+ jp.put("toolCallId", trp.getToolCallId());
+ jp.put("resultJson", trp.getResultJson());
+ } else {
+ // ImagePart: lossy. Save a placeholder so
+ // the message order is preserved.
+ jp.put("kind", KIND_TEXT);
+ jp.put("text", "[image]");
+ }
+ parts.add(jp);
+ }
+ jm.put("parts", parts);
+ if (m.getToolCallId() != null) {
+ jm.put("toolCallId", m.getToolCallId());
+ }
+ serialized.add(jm);
+ }
+ }
+ Map root = new HashMap();
+ root.put("messages", serialized);
+ byte[] payload = JSONParser.toJson(root).getBytes("UTF-8");
+ Storage.getInstance().writeObject(storageKey, payload);
+ }
+
+ public List load() throws IOException {
+ Object raw = Storage.getInstance().readObject(storageKey);
+ if (raw == null) {
+ return new ArrayList();
+ }
+ if (!(raw instanceof byte[])) {
+ // Old-format / accidental overwrite. Treat as empty
+ // rather than crashing.
+ return new ArrayList();
+ }
+ Map root = JSONParser.parseJSON((byte[]) raw);
+ List serialized = JSONParser.asList(root.get("messages"));
+ if (serialized == null) {
+ return new ArrayList();
+ }
+ List out = new ArrayList(serialized.size());
+ for (Object rawJm : serialized) {
+ Map jm = JSONParser.asMap(rawJm);
+ Role role = parseRole(JSONParser.getString(jm, "role"));
+ List parts = new ArrayList();
+ List jparts = JSONParser.asList(jm.get("parts"));
+ if (jparts != null) {
+ for (Object rawJp : jparts) {
+ Map jp = JSONParser.asMap(rawJp);
+ String kind = JSONParser.getString(jp, "kind");
+ if (KIND_TOOL_RESULT.equals(kind)) {
+ parts.add(new ToolResultPart(
+ JSONParser.getString(jp, "toolCallId"),
+ JSONParser.getString(jp, "resultJson")));
+ } else {
+ parts.add(new TextPart(JSONParser.getString(jp, "text")));
+ }
+ }
+ }
+ out.add(new ChatMessage(role, parts,
+ null, null, JSONParser.getString(jm, "toolCallId")));
+ }
+ return out;
+ }
+
+ public void clear() {
+ Storage.getInstance().deleteStorageFile(storageKey);
+ }
+
+ public String getStorageKey() {
+ return storageKey;
+ }
+
+ private static Role parseRole(String name) {
+ if (name == null) {
+ return Role.USER;
+ }
+ try {
+ return Role.valueOf(name);
+ } catch (IllegalArgumentException iae) {
+ return Role.USER;
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/Embedding.java b/CodenameOne/src/com/codename1/ai/Embedding.java
new file mode 100644
index 0000000000..bb49091a2b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/Embedding.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// A single embedding vector. `index` matches the position of the
+/// corresponding input string in the original request.
+public final class Embedding {
+ private final float[] vector;
+ private final int index;
+
+ public Embedding(float[] vector, int index) {
+ this.vector = vector == null ? new float[0] : vector;
+ this.index = index;
+ }
+
+ public float[] getVector() {
+ return vector;
+ }
+
+ public int getIndex() {
+ return index;
+ }
+
+ public int getDimensions() {
+ return vector.length;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java b/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java
new file mode 100644
index 0000000000..d8cf2b7e0b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/// Request payload for [LlmClient#embed(EmbeddingRequest)]. Carries
+/// one or more input strings plus optional `dimensions` (OpenAI's
+/// `dimensions`, Gemini's `outputDimensionality`). A `null` value means
+/// "use the model's default dimensionality".
+public final class EmbeddingRequest {
+ private final String model;
+ private final List inputs;
+ private final Integer dimensions;
+
+ private EmbeddingRequest(Builder b) {
+ this.model = b.model;
+ this.inputs = Collections.unmodifiableList(new ArrayList(b.inputs));
+ this.dimensions = b.dimensions;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /// Convenience for the single-input case.
+ public static EmbeddingRequest of(String model, String text) {
+ return builder().model(model).inputs(Arrays.asList(text)).build();
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public List getInputs() {
+ return inputs;
+ }
+
+ public Integer getDimensions() {
+ return dimensions;
+ }
+
+ public static final class Builder {
+ private String model;
+ private List inputs = new ArrayList();
+ private Integer dimensions;
+
+ Builder() {
+ }
+
+ public Builder model(String m) {
+ this.model = m;
+ return this;
+ }
+
+ public Builder inputs(List in) {
+ this.inputs = in == null ? new ArrayList()
+ : new ArrayList(in);
+ return this;
+ }
+
+ public Builder addInput(String s) {
+ this.inputs.add(s);
+ return this;
+ }
+
+ public Builder dimensions(Integer d) {
+ this.dimensions = d;
+ return this;
+ }
+
+ public EmbeddingRequest build() {
+ if (inputs.isEmpty()) {
+ throw new IllegalStateException("at least one input is required");
+ }
+ return new EmbeddingRequest(this);
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java b/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java
new file mode 100644
index 0000000000..d2553a48e9
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class EmbeddingResponse {
+ private final List data;
+ private final Usage usage;
+ private final String modelUsed;
+
+ public EmbeddingResponse(List data, Usage usage, String modelUsed) {
+ this.data = data == null ? Collections.emptyList()
+ : Collections.unmodifiableList(new ArrayList(data));
+ this.usage = usage;
+ this.modelUsed = modelUsed;
+ }
+
+ public List getData() {
+ return data;
+ }
+
+ public Usage getUsage() {
+ return usage;
+ }
+
+ public String getModelUsed() {
+ return modelUsed;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/GeminiClient.java b/CodenameOne/src/com/codename1/ai/GeminiClient.java
new file mode 100644
index 0000000000..930aad4f8c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/GeminiClient.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.util.AsyncResource;
+
+/// Google Gemini client. The native wire format diverges from OpenAI's:
+/// system messages live in `systemInstruction`, content is split into
+/// `parts` with `inline_data` / `text`, tool calls arrive atomically
+/// at stream end rather than fragment-by-fragment.
+///
+/// Google publishes an OpenAI-compatibility endpoint at
+/// `https://generativelanguage.googleapis.com/v1beta/openai/` that
+/// works with [LlmClient#localOpenAiCompatible] today; this dedicated
+/// client (which handles the native shape end-to-end) is a follow-up.
+class GeminiClient extends LlmClient {
+ private final String apiKey;
+
+ GeminiClient(String apiKey, String baseUrl) {
+ super(baseUrl);
+ this.apiKey = apiKey;
+ }
+
+ @Override
+ public String getProvider() {
+ return "gemini";
+ }
+
+ @Override
+ public AsyncResource chat(ChatRequest req) {
+ AsyncResource r = new AsyncResource();
+ r.error(new UnsupportedOperationException(
+ "GeminiClient (native) is not yet implemented in this release. "
+ + "Use LlmClient.localOpenAiCompatible("
+ + "\"https://generativelanguage.googleapis.com/v1beta/openai\", apiKey, model) "
+ + "to reach Gemini through Google's OpenAI-compatible shim."));
+ return r;
+ }
+
+ @Override
+ public AsyncResource chatStream(ChatRequest req, StreamingListener listener) {
+ return chat(req);
+ }
+
+ @Override
+ public AsyncResource embed(EmbeddingRequest req) {
+ AsyncResource r = new AsyncResource();
+ r.error(new UnsupportedOperationException(
+ "GeminiClient.embed is not yet implemented. Use the OpenAI-compatible shim "
+ + "or LlmClient.openai(...) with text-embedding-3-small."));
+ return r;
+ }
+
+ String getApiKey() {
+ return apiKey;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java b/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java
new file mode 100644
index 0000000000..1f308e137e
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Request payload for [ImageGenerator#generate(GenerateImageRequest)].
+public final class GenerateImageRequest {
+ private final String prompt;
+ private String model;
+ private String size = "1024x1024";
+ private String style;
+ private String quality;
+ private int count = 1;
+ private Long seed;
+
+ public GenerateImageRequest(String prompt) {
+ if (prompt == null || prompt.length() == 0) {
+ throw new IllegalArgumentException("prompt is required");
+ }
+ this.prompt = prompt;
+ }
+
+ public String getPrompt() {
+ return prompt;
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public GenerateImageRequest setModel(String model) {
+ this.model = model;
+ return this;
+ }
+
+ public String getSize() {
+ return size;
+ }
+
+ /// `"1024x1024"`, `"1024x1792"`, `"1792x1024"` for DALL-E 3.
+ /// Default is `"1024x1024"`.
+ public GenerateImageRequest setSize(String size) {
+ this.size = size;
+ return this;
+ }
+
+ public String getStyle() {
+ return style;
+ }
+
+ public GenerateImageRequest setStyle(String style) {
+ this.style = style;
+ return this;
+ }
+
+ public String getQuality() {
+ return quality;
+ }
+
+ public GenerateImageRequest setQuality(String quality) {
+ this.quality = quality;
+ return this;
+ }
+
+ public int getCount() {
+ return count;
+ }
+
+ /// Number of images to generate (DALL-E 3 supports 1; older
+ /// models up to 10).
+ public GenerateImageRequest setCount(int count) {
+ this.count = Math.max(1, count);
+ return this;
+ }
+
+ public Long getSeed() {
+ return seed;
+ }
+
+ public GenerateImageRequest setSeed(Long seed) {
+ this.seed = seed;
+ return this;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ImageGenerator.java b/CodenameOne/src/com/codename1/ai/ImageGenerator.java
new file mode 100644
index 0000000000..dc0e6fc841
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ImageGenerator.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.JSONParser;
+import com.codename1.io.NetworkManager;
+import com.codename1.ui.Display;
+import com.codename1.ui.Image;
+import com.codename1.util.AsyncResource;
+import com.codename1.util.Base64;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/// Cloud-first image generation. The `openai` and `replicate` factory
+/// methods cover the two dominant managed endpoints. On-device
+/// generation via Core ML / ONNX Stable Diffusion is provided
+/// separately by the optional `cn1-ai-stablediffusion` cn1lib; see
+/// [#onDevice()].
+///
+/// ```
+/// ImageGenerator.openai(KeyStore.get("openai_key"))
+/// .generate(new GenerateImageRequest("A cat in a sombrero").setSize("1024x1024"))
+/// .ready(img -> imageComponent.setIcon(img));
+/// ```
+public abstract class ImageGenerator {
+
+ public static ImageGenerator openai(String apiKey) {
+ return new OpenAiImageGenerator(apiKey);
+ }
+
+ /// Replicate runs a wide catalog of third-party image models
+ /// (SDXL, Flux, etc.) behind a uniform REST API. Pass the API
+ /// token from `https://replicate.com/account`.
+ public static ImageGenerator replicate(String apiKey) {
+ return new ReplicateImageGenerator(apiKey);
+ }
+
+ /// On-device generator. Requires the optional cn1lib
+ /// `cn1-ai-stablediffusion`; without it this returns an
+ /// `AsyncResource` that completes with
+ /// `UnsupportedOperationException`.
+ public static ImageGenerator onDevice() {
+ // Lazy lookup so app code can compile even without the
+ // cn1lib. The cn1lib registers an implementation via
+ // `NativeLookup.register(...)` when it ships, but for the
+ // base framework we just return a no-op stub.
+ return new ImageGenerator() {
+ @Override
+ public AsyncResource generate(GenerateImageRequest req) {
+ AsyncResource out = new AsyncResource();
+ out.error(new UnsupportedOperationException(
+ "On-device image generation requires the cn1-ai-stablediffusion cn1lib. "
+ + "Add it to your dependencies or use ImageGenerator.openai(...) instead."));
+ return out;
+ }
+ };
+ }
+
+ public abstract AsyncResource generate(GenerateImageRequest req);
+
+ // --------------------- OpenAI ---------------------
+
+ private static final class OpenAiImageGenerator extends ImageGenerator {
+ private final String apiKey;
+
+ OpenAiImageGenerator(String apiKey) {
+ this.apiKey = apiKey == null ? "" : apiKey;
+ }
+
+ @Override
+ public AsyncResource generate(GenerateImageRequest req) {
+ final AsyncResource result = new AsyncResource();
+ final byte[] body;
+ try {
+ Map root = new HashMap();
+ root.put("model", req.getModel() != null ? req.getModel() : "dall-e-3");
+ root.put("prompt", req.getPrompt());
+ root.put("n", Integer.valueOf(req.getCount()));
+ root.put("size", req.getSize());
+ if (req.getStyle() != null) {
+ root.put("style", req.getStyle());
+ }
+ if (req.getQuality() != null) {
+ root.put("quality", req.getQuality());
+ }
+ // b64_json keeps the request self-contained; the
+ // alternative `url` requires a second fetch and
+ // expires after an hour, which is hostile to caching.
+ root.put("response_format", "b64_json");
+ body = JSONParser.toJson(root).getBytes("UTF-8");
+ } catch (IOException ioe) {
+ result.error(ioe);
+ return result;
+ }
+ ConnectionRequest cr = new ConnectionRequest() {
+ @Override
+ protected void buildRequestBody(OutputStream os) throws IOException {
+ os.write(body);
+ }
+
+ @Override
+ protected void handleErrorResponseCode(int code, String message) {
+ }
+
+ @Override
+ protected void postResponse() {
+ try {
+ Map root = JSONParser.parseJSON(getResponseData());
+ List data = JSONParser.asList(root.get("data"));
+ if (data == null || data.isEmpty()) {
+ failOnEdt(result, new LlmException(
+ "Empty data[] in image generation response", 200, null, null, null, LlmException.ErrorType.INVALID_REQUEST));
+ return;
+ }
+ Map first = JSONParser.asMap(data.get(0));
+ String b64 = JSONParser.getString(first, "b64_json");
+ if (b64 == null) {
+ failOnEdt(result, new LlmException(
+ "Missing b64_json in image generation response", 200, null, null, null, LlmException.ErrorType.INVALID_REQUEST));
+ return;
+ }
+ byte[] bytes = Base64.decode(b64.getBytes("UTF-8"));
+ final Image img = Image.createImage(bytes, 0, bytes.length);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.complete(img);
+ }
+ });
+ } catch (IOException ex) {
+ failOnEdt(result, new LlmException("Failed to decode image", ex));
+ } catch (RuntimeException ex) {
+ failOnEdt(result, new LlmException("Failed to decode image", ex));
+ }
+ }
+
+ @Override
+ protected void handleException(Exception err) {
+ int sc;
+ try {
+ sc = getResponseCode();
+ } catch (Throwable ignore) {
+ sc = -1;
+ }
+ String bodyText = "";
+ try {
+ byte[] d = getResponseData();
+ bodyText = d == null ? "" : new String(d, "UTF-8");
+ } catch (UnsupportedEncodingException ignored) {
+ // UTF-8 is universally available; defensive only.
+ }
+ failOnEdt(result, OpenAiSseDecoder.mapErrorStatic(sc, bodyText));
+ }
+ };
+ cr.setUrl("https://api.openai.com/v1/images/generations");
+ cr.setPost(true);
+ cr.setReadResponseForErrors(true);
+ cr.setDuplicateSupported(true);
+ cr.setContentType("application/json");
+ cr.setTimeout(120000);
+ if (apiKey.length() > 0) {
+ cr.addRequestHeader("Authorization", "Bearer " + apiKey);
+ }
+ NetworkManager.getInstance().addToQueue(cr);
+ return result;
+ }
+ }
+
+ // --------------------- Replicate ---------------------
+
+ private static final class ReplicateImageGenerator extends ImageGenerator {
+ @SuppressWarnings("PMD.UnusedFormalParameter")
+ ReplicateImageGenerator(String apiKey) {
+ // apiKey is currently unused -- this generator is a
+ // scaffold pending long-poll support. The parameter is
+ // accepted so the factory signature stays stable when
+ // the real implementation lands.
+ }
+
+ @Override
+ public AsyncResource generate(GenerateImageRequest req) {
+ AsyncResource result = new AsyncResource();
+ // Replicate's "predictions" API is async/polled; full
+ // long-poll support is a follow-up. For now we surface a
+ // clear error so callers know to use a self-hosted
+ // Replicate-compatible endpoint or the OpenAI path.
+ result.error(new UnsupportedOperationException(
+ "Replicate's prediction API requires long-polling that is not implemented yet. "
+ + "Use ImageGenerator.openai(...) or run a Replicate-compatible server behind LlmClient.localOpenAiCompatible(...)."));
+ return result;
+ }
+ }
+
+ private static void failOnEdt(final AsyncResource result, final Throwable t) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.error(t);
+ }
+ });
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ImagePart.java b/CodenameOne/src/com/codename1/ai/ImagePart.java
new file mode 100644
index 0000000000..df8a81e042
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ImagePart.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// An image attachment for a multi-modal [ChatMessage]. Construct from
+/// raw bytes (the provider encodes them as base64 inline data) or from
+/// a publicly-reachable URL -- both modes are accepted by OpenAI,
+/// Anthropic, and Gemini.
+public final class ImagePart extends MessagePart {
+ private final byte[] data;
+ private final String mimeType;
+ private final String url;
+
+ /// Inline image bytes. `mimeType` must be set (e.g. `"image/png"`,
+ /// `"image/jpeg"`); the providers reject inline images without it.
+ public ImagePart(byte[] data, String mimeType) {
+ if (data == null || mimeType == null) {
+ throw new IllegalArgumentException("data and mimeType are required");
+ }
+ this.data = data;
+ this.mimeType = mimeType;
+ this.url = null;
+ }
+
+ /// Remote image by URL. Only HTTPS is portable across providers.
+ public ImagePart(String url) {
+ if (url == null) {
+ throw new IllegalArgumentException("url is required");
+ }
+ this.data = null;
+ this.mimeType = null;
+ this.url = url;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public boolean isUrl() {
+ return url != null;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/LlmChatBinding.java b/CodenameOne/src/com/codename1/ai/LlmChatBinding.java
new file mode 100644
index 0000000000..b4618cc219
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/LlmChatBinding.java
@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.components.ChatBubble;
+import com.codename1.components.ChatView;
+import com.codename1.ui.events.ActionEvent;
+import com.codename1.ui.events.ActionListener;
+import com.codename1.util.AsyncResource;
+import com.codename1.util.SuccessCallback;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/// Convenience wiring that turns a [ChatView] into an LLM-driven
+/// chat surface in one call. Pulls the user's input out of the
+/// view's [com.codename1.components.ChatInput], appends it as a USER
+/// message, opens a streaming chat call against the supplied
+/// [LlmClient], and pipes deltas into a freshly-appended assistant
+/// bubble.
+///
+/// `ChatView` itself has no dependency on the AI package, so apps
+/// that want a peer-to-peer messaging UI (a WhatsApp clone, for
+/// example) can keep using `ChatView` without pulling LlmClient
+/// onto their classpath -- it's only when you call
+/// [#bind(ChatView, LlmClient, ChatRequest)] that the binding is
+/// established.
+///
+/// #### Example
+///
+/// ```
+/// LlmClient client = LlmClient.openai(SecureStorage.getInstance().get("openai_key"));
+/// ChatRequest base = ChatRequest.builder()
+/// .model("gpt-4o-mini")
+/// .addMessage(ChatMessage.system("You are a terse assistant."))
+/// .build();
+/// ChatView view = new ChatView();
+/// LlmChatBinding.bind(view, client, base);
+/// // ...add view to a Form and that's it.
+/// ```
+///
+/// The view's accumulated history is replayed on every turn so the
+/// model has full conversation context. The original `baseRequest`
+/// is treated as a template -- its model, tools, temperature, etc.
+/// are preserved across turns; its messages are used only when the
+/// view's own history is empty (e.g. to seed a system prompt).
+public final class LlmChatBinding {
+
+ private LlmChatBinding() {
+ }
+
+ public static void bind(final ChatView view,
+ final LlmClient client,
+ final ChatRequest baseRequest) {
+ view.setOnSend(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ String text = view.getInput().getText();
+ if (text == null || text.length() == 0) {
+ return;
+ }
+ view.getInput().clear();
+ view.addMessage(ChatMessage.user(text));
+ view.setTypingIndicatorVisible(true);
+ final ChatBubble assistant = view.beginAssistantStream();
+ ChatRequest replay = baseRequest.toBuilder()
+ .messages(buildOutgoingMessages(view, baseRequest))
+ .build();
+ AsyncResource result = client.chatStream(replay,
+ new StreamingListener() {
+ @Override
+ public void onContentDelta(String textDelta) {
+ assistant.appendText(textDelta);
+ }
+
+ @Override
+ public void onToolCallDelta(int index, String id, String name, String argumentsFragment) {
+ // The default binding doesn't surface
+ // tool calls -- apps that use tools
+ // should wire up their own handler
+ // around client.chatStream(...).
+ }
+
+ @Override
+ public void onUsage(Usage usage) {
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ assistant.appendText("\n\n[error: " + t.getMessage() + "]");
+ }
+ });
+ result.ready(new SuccessCallback() {
+ @Override
+ public void onSucess(ChatResponse arg) {
+ view.setTypingIndicatorVisible(false);
+ }
+ });
+ }
+ });
+ }
+
+ private static List buildOutgoingMessages(ChatView view, ChatRequest baseRequest) {
+ List history = view.getHistory();
+ if (history.isEmpty()) {
+ return baseRequest.getMessages();
+ }
+ return new ArrayList(history);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/LlmClient.java b/CodenameOne/src/com/codename1/ai/LlmClient.java
new file mode 100644
index 0000000000..73333c86c3
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/LlmClient.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.util.AsyncResource;
+
+/// Provider-agnostic chat / embeddings client. Built via one of the
+/// static factory methods:
+///
+/// ```
+/// LlmClient gpt = LlmClient.openai("sk-...");
+/// LlmClient claude = LlmClient.anthropic("sk-ant-...");
+/// LlmClient gemini = LlmClient.gemini("AIza...");
+/// LlmClient ollama = LlmClient.ollama(); // localhost:11434
+/// LlmClient local = LlmClient.localOpenAiCompatible(
+/// "http://10.0.0.5:8080/v1", "", "qwen2.5-7b");
+/// ```
+///
+/// All calls return [AsyncResource] so they compose naturally with the
+/// rest of the Codename One async API. `chatStream` additionally fires
+/// per-token deltas through a [StreamingListener]; both that listener
+/// and the final `AsyncResource` complete on the EDT.
+///
+/// #### Simulator behaviour
+///
+/// When running in the JavaSE simulator with `cn1.ai.simulatorRedirect`
+/// set to `ollama` (or `auto` with Ollama detected on the loopback),
+/// the static factories transparently route through a local Ollama
+/// endpoint instead of the public provider -- so unchanged production
+/// code can be debugged offline without API charges.
+public abstract class LlmClient {
+ // Centralised endpoint defaults. Override with setBaseUrl(...)
+ // for self-hosted gateways, regional endpoints, or to pin a
+ // specific API version. The provider's v1 / v1beta / v2 path
+ // segment is part of these URLs intentionally so a caller who
+ // points setBaseUrl at "https://my-proxy.example.com/openai/v2"
+ // does not need to remember which suffix the official endpoint
+ // uses.
+ //
+ // Versions reflect the providers' production REST shapes as of
+ // mid-2026:
+ // - OpenAI Chat Completions -- /v1 (stable)
+ // - Anthropic Messages -- /v1 (stable)
+ // - Google Gemini (native) -- /v1beta (only path
+ // that exposes streaming generateContent and tool calls today)
+ // - Ollama OpenAI-compat shim -- /v1
+ public static final String DEFAULT_OPENAI_URL = "https://api.openai.com/v1";
+ public static final String DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com/v1";
+ public static final String DEFAULT_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta";
+ public static final String DEFAULT_OLLAMA_URL = "http://localhost:11434/v1";
+
+ private String baseUrl;
+ private int httpTimeoutMs = 60000;
+
+ protected LlmClient(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+ /// OpenAI / OpenAI-compatible (Together, Groq, Fireworks, vLLM,
+ /// Ollama, etc.). Uses the public endpoint by default; override
+ /// with [#setBaseUrl(String)].
+ public static LlmClient openai(String apiKey) {
+ return SimulatorRedirect.maybeWrap(new OpenAiClient(apiKey, DEFAULT_OPENAI_URL));
+ }
+
+ public static LlmClient anthropic(String apiKey) {
+ return SimulatorRedirect.maybeWrap(new AnthropicClient(apiKey, DEFAULT_ANTHROPIC_URL));
+ }
+
+ public static LlmClient gemini(String apiKey) {
+ return SimulatorRedirect.maybeWrap(new GeminiClient(apiKey, DEFAULT_GEMINI_URL));
+ }
+
+ /// Default Ollama install: `http://localhost:11434/v1`, model
+ /// `llama3.2`.
+ public static LlmClient ollama() {
+ return ollama("llama3.2");
+ }
+
+ public static LlmClient ollama(String defaultModel) {
+ return ollama(DEFAULT_OLLAMA_URL, defaultModel);
+ }
+
+ public static LlmClient ollama(String baseUrl, String defaultModel) {
+ OpenAiClient c = new OpenAiClient("ollama", baseUrl);
+ c.setDefaultModel(defaultModel);
+ return c;
+ }
+
+ /// Generic OpenAI-compatible endpoint (llama.cpp server, vLLM,
+ /// LM Studio, a custom proxy). `apiKey` may be empty for local
+ /// services that don't authenticate.
+ public static LlmClient localOpenAiCompatible(String baseUrl, String apiKey, String defaultModel) {
+ OpenAiClient c = new OpenAiClient(apiKey == null ? "" : apiKey, baseUrl);
+ c.setDefaultModel(defaultModel);
+ return c;
+ }
+
+ /// Non-streaming chat. Equivalent to `chatStream` with a no-op
+ /// listener but optimized -- the provider skips the SSE response
+ /// and returns a single JSON object.
+ public abstract AsyncResource chat(ChatRequest req);
+
+ /// Streaming chat. `listener` fires for every content delta /
+ /// tool-call fragment on the EDT. The returned `AsyncResource`
+ /// completes with the aggregated final response once the stream
+ /// ends; cancel it to close the underlying socket.
+ public abstract AsyncResource chatStream(ChatRequest req, StreamingListener listener);
+
+ public abstract AsyncResource embed(EmbeddingRequest req);
+
+ /// One of `"openai"`, `"anthropic"`, `"gemini"`, `"ollama"`,
+ /// `"local"`. Used by `ChatView` and tests to vary behaviour by
+ /// provider.
+ public abstract String getProvider();
+
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+ public int getHttpTimeoutMs() {
+ return httpTimeoutMs;
+ }
+
+ public void setHttpTimeoutMs(int httpTimeoutMs) {
+ this.httpTimeoutMs = httpTimeoutMs;
+ }
+
+ /// Helper for subclasses: applies the active [SafetyFilter] (if
+ /// any) before the network call. Returns the rejection reason on
+ /// failure, `null` to proceed.
+ protected String runSafetyFilter(ChatRequest req) {
+ if (req.getSafetyFilter() == null) {
+ return null;
+ }
+ return req.getSafetyFilter().check(req.getMessages());
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/LlmException.java b/CodenameOne/src/com/codename1/ai/LlmException.java
new file mode 100644
index 0000000000..6caf452f40
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/LlmException.java
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.io.IOException;
+
+/// The single checked-error type raised by [LlmClient]. Extends
+/// [IOException] so callers' existing network catch blocks pick it
+/// up. Inspect [#getType()] for the failure category and switch on
+/// the [ErrorType] enum -- no separate exception subclass per error.
+///
+/// ```
+/// try {
+/// ChatResponse r = client.chat(req).get();
+/// // ...
+/// } catch (AsyncExecutionException ae) {
+/// if (ae.getCause() instanceof LlmException) {
+/// LlmException e = (LlmException) ae.getCause();
+/// switch (e.getType()) {
+/// case RATE_LIMIT: scheduleRetry(e.getRetryAfterSeconds()); break;
+/// case AUTH: showLoginScreen(); break;
+/// case CONTEXT_LENGTH: trimHistory(); break;
+/// case MODEL_OVERLOADED: scheduleRetry(60); break;
+/// case INVALID_REQUEST: showError(e); break;
+/// case NETWORK: showOfflineUi(); break;
+/// default: showError(e);
+/// }
+/// }
+/// }
+/// ```
+///
+/// `httpStatus`, `providerErrorCode`, `rawBody`, and (for rate-limit
+/// errors) `retryAfterSeconds` are populated when the provider
+/// returned them. `getRetryAfterSeconds()` returns `-1` for every
+/// non-rate-limit error and for rate-limit errors where the provider
+/// didn't send a `Retry-After` header.
+public class LlmException extends IOException {
+
+ /// Coarse-grained classification of every failure path the
+ /// client can surface. Stable -- new variants are appended at
+ /// the end; existing variants do not change semantics.
+ public enum ErrorType {
+ /// 401 / 403 -- API key invalid, revoked, or lacks access to
+ /// the requested model.
+ AUTH,
+
+ /// 429 -- rate limit hit. Pair with [#getRetryAfterSeconds]
+ /// to honour the provider's backoff hint when available.
+ RATE_LIMIT,
+
+ /// 400 / 422 -- malformed request, unsupported parameter,
+ /// image too large, etc.
+ INVALID_REQUEST,
+
+ /// 400 subtype -- the conversation exceeded the model's
+ /// context window. Drop older turns and retry, or switch
+ /// to a longer-context model.
+ CONTEXT_LENGTH,
+
+ /// 503 / 529 -- the model is temporarily overloaded. Same
+ /// recovery as RATE_LIMIT but a different signal source.
+ MODEL_OVERLOADED,
+
+ /// 5xx other than 503 / 529 -- provider had an internal error.
+ SERVER,
+
+ /// DNS, TLS, read-timeout, connection reset -- the request
+ /// did not reach the provider or did not get a response back.
+ NETWORK,
+
+ /// Any failure that doesn't fit the categories above (e.g.
+ /// JSON parsing of the response failed). Treat as a generic
+ /// programming error and log [#getRawBody].
+ UNKNOWN
+ }
+
+ private final int httpStatus;
+ private final String providerErrorCode;
+ private final String rawBody;
+ private final ErrorType type;
+ private final int retryAfterSeconds;
+
+ public LlmException(String message) {
+ this(message, -1, null, null, null, ErrorType.UNKNOWN, -1);
+ }
+
+ public LlmException(String message, Throwable cause) {
+ this(message, -1, null, null, cause, ErrorType.UNKNOWN, -1);
+ }
+
+ public LlmException(String message, int httpStatus, String providerErrorCode,
+ String rawBody, Throwable cause, ErrorType type) {
+ this(message, httpStatus, providerErrorCode, rawBody, cause, type, -1);
+ }
+
+ public LlmException(String message, int httpStatus, String providerErrorCode,
+ String rawBody, Throwable cause, ErrorType type,
+ int retryAfterSeconds) {
+ super(message);
+ if (cause != null) {
+ initCause(cause);
+ }
+ this.httpStatus = httpStatus;
+ this.providerErrorCode = providerErrorCode;
+ this.rawBody = rawBody;
+ this.type = type == null ? ErrorType.UNKNOWN : type;
+ this.retryAfterSeconds = retryAfterSeconds;
+ }
+
+ /// Coarse-grained category -- the recommended switching point
+ /// for error handling. See the class javadoc for the full
+ /// pattern.
+ public ErrorType getType() {
+ return type;
+ }
+
+ public int getHttpStatus() {
+ return httpStatus;
+ }
+
+ public String getProviderErrorCode() {
+ return providerErrorCode;
+ }
+
+ public String getRawBody() {
+ return rawBody;
+ }
+
+ /// Seconds the provider asked us to wait before retrying, parsed
+ /// from a `Retry-After` header. `-1` when not applicable (the
+ /// usual case for non-`RATE_LIMIT` errors) or when the provider
+ /// did not send the header.
+ public int getRetryAfterSeconds() {
+ return retryAfterSeconds;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/MessagePart.java b/CodenameOne/src/com/codename1/ai/MessagePart.java
new file mode 100644
index 0000000000..868e685cd5
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/MessagePart.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// A single content fragment within a [ChatMessage]. Concrete subclasses
+/// are [TextPart], [ImagePart], and [ToolResultPart]. Each provider
+/// client translates parts to its own wire schema (OpenAI/Anthropic use
+/// content arrays; Gemini uses `parts` with `inline_data` / `text`).
+public abstract class MessagePart {
+ MessagePart() {
+ // package-private -- instantiate via concrete subclass
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/OpenAiClient.java b/CodenameOne/src/com/codename1/ai/OpenAiClient.java
new file mode 100644
index 0000000000..f3c3ecad86
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/OpenAiClient.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.JSONParser;
+import com.codename1.io.NetworkManager;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/// OpenAI-compatible chat / embeddings client. Drives Ollama,
+/// llama.cpp, vLLM, Together, and any other endpoint that speaks the
+/// `/v1/chat/completions` + `/v1/embeddings` shape.
+class OpenAiClient extends LlmClient {
+ private final String apiKey;
+ private String defaultModel = "gpt-4o-mini";
+
+ OpenAiClient(String apiKey, String baseUrl) {
+ super(baseUrl);
+ this.apiKey = apiKey == null ? "" : apiKey;
+ }
+
+ void setDefaultModel(String m) {
+ this.defaultModel = m;
+ }
+
+ @Override
+ public String getProvider() {
+ return "openai";
+ }
+
+ @Override
+ public AsyncResource chat(ChatRequest req) {
+ final AsyncResource result = new AsyncResource();
+ String reject = runSafetyFilter(req);
+ if (reject != null) {
+ result.error(new LlmException("Blocked by safety filter: " + reject,
+ 400, "safety_filter", null, null, LlmException.ErrorType.INVALID_REQUEST));
+ return result;
+ }
+ final byte[] body;
+ try {
+ body = buildChatBody(req, false);
+ } catch (IOException ioe) {
+ result.error(ioe);
+ return result;
+ }
+ ConnectionRequest cr = new ConnectionRequest() {
+ @Override
+ protected void buildRequestBody(OutputStream os) throws IOException {
+ os.write(body);
+ }
+
+ @Override
+ protected void handleErrorResponseCode(int code, String message) {
+ // Suppress framework's default dialog -- we'll deliver
+ // an exception through the AsyncResource instead.
+ }
+
+ @Override
+ protected void postResponse() {
+ final byte[] data = getResponseData();
+ try {
+ Map root = JSONParser.parseJSON(data);
+ final ChatResponse cr2 = OpenAiSseDecoder.parseNonStreaming(root);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.complete(cr2);
+ }
+ });
+ } catch (IOException ex) {
+ failParse(result, ex, "Failed to parse response");
+ } catch (RuntimeException ex) {
+ failParse(result, ex, "Failed to parse response");
+ }
+ }
+
+ @Override
+ protected void handleException(Exception errIn) {
+ final Exception err = errIn;
+ final int sc;
+ try {
+ sc = getResponseCode();
+ } catch (Throwable ignore) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.error(new LlmException(err.getMessage(), -1, null, null, err, LlmException.ErrorType.NETWORK));
+ }
+ });
+ return;
+ }
+ final String bodyText;
+ try {
+ byte[] d = getResponseData();
+ bodyText = d == null ? "" : new String(d, "UTF-8");
+ } catch (UnsupportedEncodingException ex) {
+ // UTF-8 is universally available; this branch is
+ // theoretical, but it satisfies the strict-catch
+ // static analyzer.
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.error(new LlmException(err.getMessage(), -1, null, null, err, LlmException.ErrorType.NETWORK));
+ }
+ });
+ return;
+ }
+ final LlmException mapped = OpenAiSseDecoder.mapErrorStatic(sc, bodyText);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.error(mapped);
+ }
+ });
+ }
+ };
+ configureRequest(cr, "/chat/completions");
+ NetworkManager.getInstance().addToQueue(cr);
+ return result;
+ }
+
+ @Override
+ public AsyncResource chatStream(ChatRequest req, StreamingListener listener) {
+ final AsyncResource result = new AsyncResource();
+ String reject = runSafetyFilter(req);
+ if (reject != null) {
+ result.error(new LlmException("Blocked by safety filter: " + reject,
+ 400, "safety_filter", null, null, LlmException.ErrorType.INVALID_REQUEST));
+ return result;
+ }
+ final byte[] body;
+ try {
+ body = buildChatBody(req, true);
+ } catch (IOException ioe) {
+ result.error(ioe);
+ return result;
+ }
+ final StreamingChatRequest scr = new StreamingChatRequest(
+ resolveUrl("/chat/completions"), body,
+ listener == null ? new StreamingListener.Adapter() : listener,
+ result,
+ new OpenAiSseDecoder(req.getModel() != null ? req.getModel() : defaultModel)) {
+ // Inherit body / SSE plumbing.
+ };
+ configureRequest(scr, null);
+ scr.addRequestHeader("Accept", "text/event-stream");
+ NetworkManager.getInstance().addToQueue(scr);
+ // Bridge cancellation: when the caller cancels the AsyncResource
+ // we kill the underlying socket so no further deltas arrive.
+ // AsyncResource is Observable and fires `setChanged()` from
+ // cancel(), complete(), and error(); we only act on cancellation.
+ result.addObserver(new java.util.Observer() {
+ @Override
+ public void update(java.util.Observable o, Object arg) {
+ if (result.isCancelled()) {
+ scr.kill();
+ }
+ }
+ });
+ return result;
+ }
+
+ @Override
+ public AsyncResource embed(EmbeddingRequest req) {
+ final AsyncResource result = new AsyncResource();
+ final byte[] body;
+ try {
+ Map root = new HashMap();
+ root.put("model", req.getModel() != null ? req.getModel() : "text-embedding-3-small");
+ if (req.getInputs().size() == 1) {
+ root.put("input", req.getInputs().get(0));
+ } else {
+ root.put("input", req.getInputs());
+ }
+ if (req.getDimensions() != null) {
+ root.put("dimensions", req.getDimensions());
+ }
+ body = JSONParser.toJson(root).getBytes("UTF-8");
+ } catch (IOException ioe) {
+ result.error(ioe);
+ return result;
+ }
+ ConnectionRequest cr = new ConnectionRequest() {
+ @Override
+ protected void buildRequestBody(OutputStream os) throws IOException {
+ os.write(body);
+ }
+
+ @Override
+ protected void handleErrorResponseCode(int code, String message) {
+ }
+
+ @Override
+ protected void postResponse() {
+ try {
+ Map root = JSONParser.parseJSON(getResponseData());
+ List dataArr = JSONParser.asList(root.get("data"));
+ List out = new ArrayList(dataArr == null ? 0 : dataArr.size());
+ if (dataArr != null) {
+ for (int i = 0; i < dataArr.size(); i++) {
+ Map e = JSONParser.asMap(dataArr.get(i));
+ List v = JSONParser.asList(e.get("embedding"));
+ float[] vec = new float[v == null ? 0 : v.size()];
+ for (int j = 0; j < vec.length; j++) {
+ Object n = v.get(j);
+ vec[j] = n instanceof Number ? ((Number) n).floatValue()
+ : Float.parseFloat(n.toString());
+ }
+ out.add(new Embedding(vec, JSONParser.getInt(e, "index", i)));
+ }
+ }
+ Map usageMap = JSONParser.asMap(root.get("usage"));
+ Usage u = usageMap == null ? null
+ : new Usage(
+ JSONParser.getInt(usageMap, "prompt_tokens", -1),
+ -1,
+ JSONParser.getInt(usageMap, "total_tokens", -1));
+ final EmbeddingResponse er = new EmbeddingResponse(out, u,
+ JSONParser.getString(root, "model"));
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.complete(er);
+ }
+ });
+ } catch (IOException ex) {
+ failParse(result, ex, "Failed to parse embedding response");
+ } catch (RuntimeException ex) {
+ failParse(result, ex, "Failed to parse embedding response");
+ }
+ }
+
+ @Override
+ protected void handleException(Exception errIn) {
+ final Exception err = errIn;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ result.error(new LlmException(err.getMessage(), -1, null, null, err, LlmException.ErrorType.NETWORK));
+ }
+ });
+ }
+ };
+ configureRequest(cr, "/embeddings");
+ NetworkManager.getInstance().addToQueue(cr);
+ return result;
+ }
+
+ private void configureRequest(ConnectionRequest cr, String pathOrNull) {
+ if (pathOrNull != null) {
+ cr.setUrl(resolveUrl(pathOrNull));
+ }
+ cr.setPost(true);
+ cr.setReadResponseForErrors(true);
+ cr.setDuplicateSupported(true);
+ cr.setContentType("application/json");
+ cr.setTimeout(getHttpTimeoutMs());
+ if (apiKey.length() > 0) {
+ cr.addRequestHeader("Authorization", "Bearer " + apiKey);
+ }
+ }
+
+ private String resolveUrl(String path) {
+ String base = getBaseUrl();
+ if (base == null) {
+ base = "";
+ }
+ if (base.endsWith("/")) {
+ base = base.substring(0, base.length() - 1);
+ }
+ if (!path.startsWith("/")) {
+ path = "/" + path;
+ }
+ return base + path;
+ }
+
+ @SuppressWarnings("unchecked")
+ private byte[] buildChatBody(ChatRequest req, boolean stream) throws IOException {
+ Map root = new HashMap();
+ root.put("model", req.getModel() != null ? req.getModel() : defaultModel);
+ root.put("messages", encodeMessages(req));
+ root.put("stream", stream ? Boolean.TRUE : Boolean.FALSE);
+ if (stream) {
+ // Ask for usage in the final SSE chunk; modern OpenAI
+ // endpoints only emit it when this option is set.
+ Map so = new HashMap();
+ so.put("include_usage", Boolean.TRUE);
+ root.put("stream_options", so);
+ }
+ if (req.getTemperature() != null) {
+ root.put("temperature", req.getTemperature());
+ }
+ if (req.getMaxTokens() != null) {
+ root.put("max_tokens", req.getMaxTokens());
+ }
+ if (req.getTopP() != null) {
+ root.put("top_p", req.getTopP());
+ }
+ if (req.getSeed() != null) {
+ root.put("seed", req.getSeed());
+ }
+ if (!req.getStopSequences().isEmpty()) {
+ root.put("stop", req.getStopSequences());
+ }
+ if (req.getResponseFormat() == ResponseFormat.JSON_OBJECT) {
+ Map rf = new HashMap();
+ rf.put("type", "json_object");
+ root.put("response_format", rf);
+ }
+ if (!req.getTools().isEmpty()) {
+ root.put("tools", encodeTools(req.getTools()));
+ if (req.getToolChoice() != null) {
+ root.put("tool_choice", encodeToolChoice(req.getToolChoice()));
+ }
+ }
+ if (!req.getMetadata().isEmpty()) {
+ root.put("metadata", req.getMetadata());
+ }
+ return JSONParser.toJson(root).getBytes("UTF-8");
+ }
+
+ private List encodeMessages(ChatRequest req) {
+ List out = new ArrayList(req.getMessages().size());
+ for (int i = 0; i < req.getMessages().size(); i++) {
+ ChatMessage m = req.getMessages().get(i);
+ Map jm = new HashMap();
+ jm.put("role", roleString(m.getRole()));
+ // OpenAI accepts content as either a string (text-only)
+ // or a content-array (multi-modal). Prefer the string
+ // form when there's only one TextPart.
+ if (m.getParts().size() == 1 && m.getParts().get(0) instanceof TextPart) {
+ jm.put("content", ((TextPart) m.getParts().get(0)).getText());
+ } else if (m.getRole() == Role.TOOL && m.getToolCallId() != null) {
+ jm.put("tool_call_id", m.getToolCallId());
+ StringBuilder buf = new StringBuilder();
+ for (int p = 0; p < m.getParts().size(); p++) {
+ MessagePart part = m.getParts().get(p);
+ if (part instanceof TextPart) {
+ buf.append(((TextPart) part).getText());
+ } else if (part instanceof ToolResultPart) {
+ buf.append(((ToolResultPart) part).getResultJson());
+ }
+ }
+ jm.put("content", buf.toString());
+ } else {
+ List parts = new ArrayList();
+ for (int p = 0; p < m.getParts().size(); p++) {
+ MessagePart part = m.getParts().get(p);
+ if (part instanceof TextPart) {
+ Map jp = new HashMap();
+ jp.put("type", "text");
+ jp.put("text", ((TextPart) part).getText());
+ parts.add(jp);
+ } else if (part instanceof ImagePart) {
+ ImagePart ip = (ImagePart) part;
+ Map jp = new HashMap();
+ jp.put("type", "image_url");
+ Map iu = new HashMap();
+ if (ip.isUrl()) {
+ iu.put("url", ip.getUrl());
+ } else {
+ iu.put("url", "data:" + ip.getMimeType() + ";base64,"
+ + com.codename1.util.Base64.encodeNoNewline(ip.getData()));
+ }
+ jp.put("image_url", iu);
+ parts.add(jp);
+ }
+ }
+ jm.put("content", parts);
+ }
+ if (m.getName() != null) {
+ jm.put("name", m.getName());
+ }
+ if (!m.getToolCalls().isEmpty()) {
+ List tcs = new ArrayList();
+ for (ToolCall tc : m.getToolCalls()) {
+ Map jtc = new HashMap();
+ jtc.put("id", tc.getId());
+ jtc.put("type", "function");
+ Map fn = new HashMap();
+ fn.put("name", tc.getName());
+ fn.put("arguments", tc.getArgumentsJson());
+ jtc.put("function", fn);
+ tcs.add(jtc);
+ }
+ jm.put("tool_calls", tcs);
+ }
+ out.add(jm);
+ }
+ return out;
+ }
+
+ private static String roleString(Role r) {
+ switch (r) {
+ case SYSTEM: return "system";
+ case USER: return "user";
+ case ASSISTANT: return "assistant";
+ case TOOL: return "tool";
+ default: return "user";
+ }
+ }
+
+ private List encodeTools(List tools) {
+ List out = new ArrayList(tools.size());
+ for (Tool t : tools) {
+ Map jt = new HashMap();
+ jt.put("type", "function");
+ Map fn = new HashMap();
+ fn.put("name", t.getName());
+ fn.put("description", t.getDescription());
+ fn.put("parameters", JSONParser.rawJson(t.getParametersJsonSchema()));
+ jt.put("function", fn);
+ out.add(jt);
+ }
+ return out;
+ }
+
+ private Object encodeToolChoice(ToolChoice c) {
+ if ("named".equals(c.getMode())) {
+ Map tc = new HashMap();
+ tc.put("type", "function");
+ Map fn = new HashMap();
+ fn.put("name", c.getForcedToolName());
+ tc.put("function", fn);
+ return tc;
+ }
+ return c.getMode();
+ }
+
+ /// Shared error-fan-out for the post-response JSON parsers.
+ /// Wraps `t` in an `LlmException` with the given message and
+ /// completes the result on the EDT. Reused by the two catch
+ /// blocks (IOException + RuntimeException) we need to keep
+ /// the strict-catch static analyzer happy.
+ private static void failParse(final AsyncResource> result,
+ final Throwable t,
+ final String message) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ ((AsyncResource) result).error(new LlmException(message, t));
+ }
+ });
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java b/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java
new file mode 100644
index 0000000000..77d2764fac
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.io.JSONParser;
+import com.codename1.ui.Display;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/// SSE decoder for OpenAI-style `/chat/completions` streams. Also
+/// drives Ollama, vLLM, and the other OpenAI-compatible endpoints
+/// because the wire format is identical.
+///
+/// Accumulates assistant content + tool-call argument fragments and
+/// assembles a final [ChatResponse] at stream close. Listener
+/// callbacks are dispatched on the EDT via [Display#callSerially].
+final class OpenAiSseDecoder implements StreamingChatRequest.SseDecoder {
+
+ private final String requestedModel;
+ // The decoder accumulates tokens for a single short-lived SSE
+ // stream; not a long-running container that risks growing
+ // unbounded -- AvoidStringBufferField doesn't apply here.
+ @SuppressWarnings("PMD.AvoidStringBufferField")
+ private final StringBuilder content = new StringBuilder();
+ private final List toolCalls = new ArrayList();
+ private String finishReason;
+ private Usage usage;
+ private String modelUsed;
+
+ OpenAiSseDecoder(String requestedModel) {
+ this.requestedModel = requestedModel;
+ }
+
+ @Override
+ public void consume(String dataPayload, final StreamingListener listener) throws Exception {
+ Map root = JSONParser.parseJSON(dataPayload);
+ if (root == null) {
+ return;
+ }
+ String modelInChunk = JSONParser.getString(root, "model");
+ if (modelInChunk != null) {
+ modelUsed = modelInChunk;
+ }
+ Map u = JSONParser.asMap(root.get("usage"));
+ if (u != null) {
+ usage = new Usage(
+ JSONParser.getInt(u, "prompt_tokens", -1),
+ JSONParser.getInt(u, "completion_tokens", -1),
+ JSONParser.getInt(u, "total_tokens", -1));
+ final Usage emit = usage;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ listener.onUsage(emit);
+ }
+ });
+ }
+ List choices = JSONParser.asList(root.get("choices"));
+ if (choices == null || choices.isEmpty()) {
+ return;
+ }
+ Map choice = JSONParser.asMap(choices.get(0));
+ String fr = JSONParser.getString(choice, "finish_reason");
+ if (fr != null) {
+ finishReason = fr;
+ }
+ Map delta = JSONParser.asMap(choice.get("delta"));
+ if (delta == null) {
+ // Non-streaming response shape can appear at the very end
+ // for some servers -- `message` instead of `delta`.
+ delta = JSONParser.asMap(choice.get("message"));
+ if (delta == null) {
+ return;
+ }
+ }
+ String contentDelta = JSONParser.getString(delta, "content");
+ if (contentDelta != null && contentDelta.length() > 0) {
+ content.append(contentDelta);
+ final String emit = contentDelta;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ listener.onContentDelta(emit);
+ }
+ });
+ }
+ List tcs = JSONParser.asList(delta.get("tool_calls"));
+ if (tcs != null) {
+ int i = 0;
+ for (Object rawTc : tcs) {
+ Map tc = JSONParser.asMap(rawTc);
+ final int idx = JSONParser.getInt(tc, "index", i);
+ while (toolCalls.size() <= idx) {
+ toolCalls.add(new StreamingToolCall());
+ }
+ StreamingToolCall acc = toolCalls.get(idx);
+ String id = JSONParser.getString(tc, "id");
+ if (id != null) {
+ acc.id = id;
+ }
+ Map fn = JSONParser.asMap(tc.get("function"));
+ String name = fn == null ? null : JSONParser.getString(fn, "name");
+ if (name != null) {
+ acc.name = name;
+ }
+ String argsFrag = fn == null ? null : JSONParser.getString(fn, "arguments");
+ if (argsFrag != null) {
+ acc.arguments.append(argsFrag);
+ }
+ final String emitId = acc.id;
+ final String emitName = name;
+ final String emitArgs = argsFrag == null ? "" : argsFrag;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ listener.onToolCallDelta(idx, emitId, emitName, emitArgs);
+ }
+ });
+ i++;
+ }
+ }
+ }
+
+ @Override
+ public ChatResponse finish() {
+ List calls = new ArrayList(toolCalls.size());
+ for (StreamingToolCall sc : toolCalls) {
+ calls.add(new ToolCall(sc.id, sc.name, sc.arguments.toString()));
+ }
+ ChatMessage assistant = new ChatMessage(Role.ASSISTANT,
+ Arrays.asList(new TextPart(content.toString())),
+ calls, null, null);
+ return new ChatResponse(assistant, calls,
+ finishReason == null ? "stop" : finishReason,
+ usage,
+ modelUsed == null ? requestedModel : modelUsed);
+ }
+
+ @Override
+ public LlmException mapError(int httpStatus, String body) {
+ return mapErrorStatic(httpStatus, body);
+ }
+
+ /// Shared with the non-streaming code path, hence static.
+ static LlmException mapErrorStatic(int httpStatus, String body) {
+ String code = null;
+ String message = body;
+ try {
+ Map root = JSONParser.parseJSON(body);
+ Map err = JSONParser.asMap(root.get("error"));
+ if (err != null) {
+ code = JSONParser.getString(err, "code");
+ String em = JSONParser.getString(err, "message");
+ if (em != null) {
+ message = em;
+ }
+ String type = JSONParser.getString(err, "type");
+ if ("context_length_exceeded".equals(code) || "context_length_exceeded".equals(type)) {
+ return new LlmException(message, 400, code, body, null, LlmException.ErrorType.CONTEXT_LENGTH);
+ }
+ }
+ } catch (java.io.IOException ignored) {
+ // Body wasn't valid JSON; fall through to status-only mapping.
+ } catch (RuntimeException ignored) {
+ // Cast / NPE while walking the error map; same fallback.
+ }
+ if (httpStatus == 401 || httpStatus == 403) {
+ return new LlmException(message, httpStatus, code, body, null, LlmException.ErrorType.AUTH);
+ }
+ if (httpStatus == 429) {
+ return new LlmException(message, 429, code, body, null, LlmException.ErrorType.RATE_LIMIT, -1);
+ }
+ if (httpStatus == 503 || httpStatus == 529) {
+ return new LlmException(message, httpStatus, code, body, null, LlmException.ErrorType.MODEL_OVERLOADED);
+ }
+ if (httpStatus >= 400 && httpStatus < 500) {
+ return new LlmException(message, httpStatus, code, body, null, LlmException.ErrorType.INVALID_REQUEST);
+ }
+ if (httpStatus >= 500) {
+ return new LlmException(message, httpStatus, code, body, null, LlmException.ErrorType.SERVER);
+ }
+ return new LlmException(message, httpStatus, code, body, null, LlmException.ErrorType.UNKNOWN);
+ }
+
+ /// Parses a single non-streaming response body into a
+ /// `ChatResponse`. Shared with the synchronous `chat()` path.
+ static ChatResponse parseNonStreaming(Map root) {
+ StringBuilder content = new StringBuilder();
+ List toolCalls = new ArrayList();
+ String finishReason = "stop";
+
+ List choices = JSONParser.asList(root.get("choices"));
+ if (choices != null && !choices.isEmpty()) {
+ Map choice = JSONParser.asMap(choices.get(0));
+ String fr = JSONParser.getString(choice, "finish_reason");
+ if (fr != null) {
+ finishReason = fr;
+ }
+ Map msg = JSONParser.asMap(choice.get("message"));
+ if (msg != null) {
+ String c = JSONParser.getString(msg, "content");
+ if (c != null) {
+ content.append(c);
+ }
+ List tcs = JSONParser.asList(msg.get("tool_calls"));
+ if (tcs != null) {
+ for (Object rawTc : tcs) {
+ Map tc = JSONParser.asMap(rawTc);
+ Map fn = JSONParser.asMap(tc.get("function"));
+ toolCalls.add(new ToolCall(
+ JSONParser.getString(tc, "id"),
+ fn == null ? null : JSONParser.getString(fn, "name"),
+ fn == null ? null : JSONParser.getString(fn, "arguments")));
+ }
+ }
+ }
+ }
+ Map u = JSONParser.asMap(root.get("usage"));
+ Usage usage = u == null ? null : new Usage(
+ JSONParser.getInt(u, "prompt_tokens", -1),
+ JSONParser.getInt(u, "completion_tokens", -1),
+ JSONParser.getInt(u, "total_tokens", -1));
+
+ ChatMessage assistant = new ChatMessage(Role.ASSISTANT,
+ Arrays.asList(new TextPart(content.toString())),
+ toolCalls, null, null);
+ return new ChatResponse(assistant, toolCalls, finishReason, usage,
+ JSONParser.getString(root, "model"));
+ }
+
+ private static final class StreamingToolCall {
+ String id;
+ String name;
+ // Accumulates argument fragments for one tool call only;
+ // same justification as `content` above.
+ @SuppressWarnings("PMD.AvoidStringBufferField")
+ StringBuilder arguments = new StringBuilder();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/PromptTemplate.java b/CodenameOne/src/com/codename1/ai/PromptTemplate.java
new file mode 100644
index 0000000000..7bfb090dfa
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/PromptTemplate.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.Map;
+
+/// Trivial `{placeholder}` substitution. Designed for the common
+/// "build a prompt from a handful of fields" pattern without pulling
+/// in a templating library. For anything more sophisticated (loops,
+/// conditionals) just compose strings directly.
+///
+/// ```
+/// String prompt = PromptTemplate.of(
+/// "You are an expert {role}. Reply in {style}."
+/// ).put("role", "tax accountant").put("style", "bullet points").build();
+/// ```
+public final class PromptTemplate {
+ private final String template;
+ private final java.util.HashMap values = new java.util.HashMap();
+
+ private PromptTemplate(String template) {
+ if (template == null) {
+ throw new IllegalArgumentException("template is required");
+ }
+ this.template = template;
+ }
+
+ public static PromptTemplate of(String template) {
+ return new PromptTemplate(template);
+ }
+
+ public PromptTemplate put(String key, String value) {
+ values.put(key, value == null ? "" : value);
+ return this;
+ }
+
+ public PromptTemplate putAll(Map map) {
+ if (map != null) {
+ values.putAll(map);
+ }
+ return this;
+ }
+
+ /// Renders the final string. Unknown placeholders are left
+ /// intact (`{like_this}`) so they're easy to spot in test
+ /// output -- silently dropping them tends to hide bugs.
+ public String build() {
+ StringBuilder out = new StringBuilder(template.length() + 32);
+ int i = 0;
+ while (i < template.length()) {
+ char c = template.charAt(i);
+ if (c == '{') {
+ int end = template.indexOf('}', i + 1);
+ if (end > i) {
+ String key = template.substring(i + 1, end);
+ String v = values.get(key);
+ if (v != null) {
+ out.append(v);
+ i = end + 1;
+ continue;
+ }
+ }
+ }
+ out.append(c);
+ i++;
+ }
+ return out.toString();
+ }
+
+ /// Convenience: render and wrap as a [ChatMessage] with USER role.
+ public ChatMessage asUser() {
+ return ChatMessage.user(build());
+ }
+
+ /// Convenience: render and wrap as a [ChatMessage] with SYSTEM role.
+ public ChatMessage asSystem() {
+ return ChatMessage.system(build());
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ResponseFormat.java b/CodenameOne/src/com/codename1/ai/ResponseFormat.java
new file mode 100644
index 0000000000..471e89a7e1
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ResponseFormat.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Constrains the model's output format. [#TEXT] is the default;
+/// [#JSON_OBJECT] forces the model to emit valid JSON (OpenAI/Gemini
+/// honour this directly; Anthropic emulates via a system-prompt
+/// guardrail in the client).
+public enum ResponseFormat {
+ TEXT,
+ JSON_OBJECT
+}
diff --git a/CodenameOne/src/com/codename1/ai/RetryPolicy.java b/CodenameOne/src/com/codename1/ai/RetryPolicy.java
new file mode 100644
index 0000000000..00beb3fd0c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/RetryPolicy.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.Random;
+
+/// Decides whether and how long to wait before retrying a failed
+/// [LlmClient] call. Default policy retries [LlmRateLimitException]
+/// (honouring `Retry-After`) and [LlmModelOverloadedException] with
+/// exponential backoff + jitter; other failures are surfaced
+/// immediately.
+///
+/// Wire a policy onto a request like this:
+///
+/// ```
+/// AsyncResource r = RetryPolicy.exponentialBackoff()
+/// .runChat(client, request);
+/// ```
+///
+/// The synchronous block runs on the calling thread. On the EDT it
+/// uses `Display.invokeAndBlock` automatically so the UI stays
+/// responsive between attempts.
+public final class RetryPolicy {
+ private final int maxAttempts;
+ private final long initialDelayMs;
+ private final long maxDelayMs;
+ private final double multiplier;
+ private final boolean jitter;
+
+ private static final Random RNG = new Random();
+
+ private RetryPolicy(int maxAttempts, long initialDelayMs, long maxDelayMs,
+ double multiplier, boolean jitter) {
+ this.maxAttempts = Math.max(1, maxAttempts);
+ this.initialDelayMs = Math.max(1, initialDelayMs);
+ this.maxDelayMs = Math.max(initialDelayMs, maxDelayMs);
+ this.multiplier = Math.max(1.0, multiplier);
+ this.jitter = jitter;
+ }
+
+ /// 4 attempts, starting at 500 ms, doubling, capped at 30 s, with
+ /// jitter. Good default for chat workloads.
+ public static RetryPolicy exponentialBackoff() {
+ return new RetryPolicy(4, 500L, 30000L, 2.0, true);
+ }
+
+ /// No retries -- failures are returned to the caller as-is.
+ public static RetryPolicy none() {
+ return new RetryPolicy(1, 0L, 0L, 1.0, false);
+ }
+
+ public static RetryPolicy custom(int maxAttempts, long initialDelayMs,
+ long maxDelayMs, double multiplier, boolean jitter) {
+ return new RetryPolicy(maxAttempts, initialDelayMs, maxDelayMs, multiplier, jitter);
+ }
+
+ /// Inspect a thrown exception and decide whether to retry. Apps
+ /// can override to add provider-specific rules (e.g. retry on a
+ /// custom 5xx code).
+ public boolean shouldRetry(Throwable t, int attemptsSoFar) {
+ if (attemptsSoFar >= maxAttempts) {
+ return false;
+ }
+ if ((t instanceof LlmException && ((LlmException) t).getType() == LlmException.ErrorType.RATE_LIMIT)) {
+ return true;
+ }
+ if ((t instanceof LlmException && ((LlmException) t).getType() == LlmException.ErrorType.MODEL_OVERLOADED)) {
+ return true;
+ }
+ if ((t instanceof LlmException && ((LlmException) t).getType() == LlmException.ErrorType.SERVER)) {
+ // 5xx server errors typically reflect transient state.
+ return true;
+ }
+ return (t instanceof LlmException && ((LlmException) t).getType() == LlmException.ErrorType.NETWORK);
+ }
+
+ /// Returns the delay to wait before the next attempt, honouring
+ /// `Retry-After` from rate-limit exceptions when present.
+ public long computeDelayMs(Throwable t, int attemptIndex /* 0-based */) {
+ if (t instanceof LlmException
+ && ((LlmException) t).getType() == LlmException.ErrorType.RATE_LIMIT) {
+ int retryAfter = ((LlmException) t).getRetryAfterSeconds();
+ if (retryAfter > 0) {
+ return retryAfter * 1000L;
+ }
+ }
+ double delay = initialDelayMs;
+ for (int i = 0; i < attemptIndex; i++) {
+ delay *= multiplier;
+ if (delay >= maxDelayMs) {
+ delay = maxDelayMs;
+ break;
+ }
+ }
+ if (jitter) {
+ // Full jitter: pick a random value in [0, delay].
+ // Keeps thundering-herd risk down on shared endpoints.
+ delay = RNG.nextDouble() * delay;
+ }
+ return (long) delay;
+ }
+
+ public int getMaxAttempts() {
+ return maxAttempts;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/Role.java b/CodenameOne/src/com/codename1/ai/Role.java
new file mode 100644
index 0000000000..3c7a86197c
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/Role.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Author of a [ChatMessage]. Maps directly to the role string sent on
+/// the wire by every supported LLM provider.
+public enum Role {
+ /// Developer-supplied instructions that steer the assistant. Sent
+ /// once at the head of the conversation. Gemini receives these as
+ /// `systemInstruction` rather than a normal message; the Gemini
+ /// client handles the conversion internally.
+ SYSTEM,
+
+ /// End-user content (text, images, tool results).
+ USER,
+
+ /// Model output. When the assistant calls a tool the
+ /// [ChatMessage] also carries one or more [ToolCall] entries.
+ ASSISTANT,
+
+ /// Result of a function/tool invocation, sent back to the model so
+ /// it can continue reasoning. Paired with the originating
+ /// [ToolCall] via [ChatMessage#getToolCallId()].
+ TOOL
+}
diff --git a/CodenameOne/src/com/codename1/ai/SafetyFilter.java b/CodenameOne/src/com/codename1/ai/SafetyFilter.java
new file mode 100644
index 0000000000..00ce689ec3
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/SafetyFilter.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.List;
+
+/// Pre-flight gate that inspects messages before they're sent to the
+/// model. Implementations may call a moderation API, run a local
+/// profanity match, or anything else. Returning a non-null reason
+/// blocks the chat call and propagates an [LlmInvalidRequestException].
+public interface SafetyFilter {
+ /// Returns `null` to allow the call, or a human-readable reason
+ /// string to block it.
+ String check(List messages);
+
+ /// A built-in filter that allows everything. Useful as a default
+ /// or as a base for composition. Use `SafetyFilters.openai(key)`
+ /// (in `com.codename1.ai.filters` or a separate cn1lib) for the
+ /// OpenAI Moderation gate.
+ SafetyFilter ALLOW_ALL = new SafetyFilter() {
+ @Override
+ public String check(List messages) {
+ return null;
+ }
+ };
+}
diff --git a/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java b/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java
new file mode 100644
index 0000000000..f4cacca206
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.ui.Display;
+
+/// Centralizes the JavaSE simulator's "redirect every LLM call to a
+/// local Ollama" behaviour. Always a no-op on device builds because
+/// the relevant system property is only set inside `JavaSEPort`.
+///
+/// Three modes via `cn1.ai.simulatorRedirect`:
+/// - `disabled` / unset on device: passthrough.
+/// - `auto`: on simulator only; if `JavaSEPort` detected Ollama on
+/// startup, redirect. Default in the simulator.
+/// - `ollama`: force-redirect regardless of detection.
+///
+/// The decision is made *per factory call* so an app can flip the
+/// property at runtime (the simulator's Tools menu does this).
+final class SimulatorRedirect {
+
+ private SimulatorRedirect() {
+ }
+
+ static LlmClient maybeWrap(LlmClient real) {
+ if (!isSimulator()) {
+ return real;
+ }
+ // CN1 core's java.lang.System exposes only the single-arg
+ // getProperty; default-value handling is done by hand.
+ String mode = readProperty("cn1.ai.simulatorRedirect", "auto");
+ boolean force = "ollama".equalsIgnoreCase(mode);
+ boolean auto = "auto".equalsIgnoreCase(mode);
+ if (!force && !auto) {
+ return real;
+ }
+ if (auto && !"true".equals(readProperty("cn1.ai.ollamaDetected", "false"))) {
+ return real;
+ }
+ // Build a fresh Ollama-pointed OpenAI-compatible client. We
+ // intentionally lose the original baseUrl/key here; the user
+ // opted into local mode and the simulator banner already
+ // disclosed that.
+ String localUrl = readProperty("cn1.ai.ollamaUrl", "http://localhost:11434/v1");
+ String model = readProperty("cn1.ai.ollamaModel", "llama3.2");
+ return LlmClient.localOpenAiCompatible(localUrl, "", model);
+ }
+
+ private static String readProperty(String key, String defaultValue) {
+ String v = System.getProperty(key);
+ return v != null ? v : defaultValue;
+ }
+
+ private static boolean isSimulator() {
+ try {
+ String platform = Display.getInstance().getPlatformName();
+ // JavaSEPort returns "se" for the simulator on most
+ // configurations; check defensively in case that ever
+ // changes.
+ return "se".equalsIgnoreCase(platform)
+ || "javase".equalsIgnoreCase(platform);
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java b/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java
new file mode 100644
index 0000000000..fd6e218222
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.Log;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/// Internal `ConnectionRequest` subclass that streams an SSE response
+/// line-by-line, dispatching each `data:` payload through a
+/// provider-specific parser. Provider-agnostic plumbing (line
+/// reassembly, EDT dispatch, error mapping, cancellation, completion
+/// signaling) lives here; the parser and the request body are
+/// supplied by the concrete provider client.
+abstract class StreamingChatRequest extends ConnectionRequest {
+ private final StreamingListener listener;
+ private final AsyncResource result;
+ private final byte[] requestBody;
+
+ /// Provider-supplied accumulator that aggregates `data:` lines
+ /// into a final [ChatResponse] and fires per-delta listener
+ /// callbacks along the way.
+ private final SseDecoder decoder;
+
+ StreamingChatRequest(String url, byte[] requestBody,
+ StreamingListener listener,
+ AsyncResource result,
+ SseDecoder decoder) {
+ setUrl(url);
+ setPost(true);
+ setReadResponseForErrors(true);
+ setContentType("application/json");
+ // The framework's default duplicate-suppression collapses two
+ // identical-URL chat calls into one; that's actively wrong
+ // for chats, so opt out.
+ setDuplicateSupported(true);
+ this.requestBody = requestBody;
+ this.listener = listener;
+ this.result = result;
+ this.decoder = decoder;
+ }
+
+ @Override
+ protected void buildRequestBody(OutputStream os) throws IOException {
+ if (requestBody != null) {
+ os.write(requestBody);
+ }
+ }
+
+ @Override
+ protected void readResponse(InputStream input) throws IOException {
+ int status = getResponseCode();
+ if (status >= 400) {
+ // The framework will normally pump the body into `data`
+ // and we map to an LlmException via handleErrorResponseCode.
+ // Still drain so the body is captured.
+ // Util.readInputStream is documented to never return null;
+ // skip the redundant null guard to satisfy the static
+ // analyzer.
+ byte[] body = com.codename1.io.Util.readInputStream(input);
+ String text = new String(body, "UTF-8");
+ failWith(decoder.mapError(status, text));
+ return;
+ }
+
+ StringBuilder lineBuf = new StringBuilder(256);
+ StringBuilder dataBuf = new StringBuilder(1024);
+ int b;
+ while (!isKilled() && (b = input.read()) != -1) {
+ if (b == '\n') {
+ String line = lineBuf.toString();
+ lineBuf.setLength(0);
+ if (line.length() > 0 && line.charAt(line.length() - 1) == '\r') {
+ line = line.substring(0, line.length() - 1);
+ }
+ if (line.length() == 0) {
+ // Dispatch the accumulated event.
+ if (dataBuf.length() > 0) {
+ dispatchEvent(dataBuf.toString());
+ dataBuf.setLength(0);
+ }
+ } else if (line.startsWith("data:")) {
+ String payload = line.substring(5);
+ if (payload.length() > 0 && payload.charAt(0) == ' ') {
+ payload = payload.substring(1);
+ }
+ if (dataBuf.length() > 0) {
+ dataBuf.append('\n');
+ }
+ dataBuf.append(payload);
+ }
+ // Ignore other line types (event:, id:, retry:, comments).
+ } else {
+ lineBuf.append((char) b);
+ }
+ }
+ // Final dispatch for any trailing event without a blank-line
+ // terminator (some providers omit the trailing CRLF).
+ if (dataBuf.length() > 0) {
+ dispatchEvent(dataBuf.toString());
+ }
+ if (isKilled()) {
+ return;
+ }
+ // Stream ended without an explicit terminator from the
+ // decoder; ask it to finalize whatever it has.
+ ChatResponse finalResponse = decoder.finish();
+ completeWith(finalResponse);
+ }
+
+ private void dispatchEvent(String payload) {
+ if ("[DONE]".equals(payload)) {
+ // OpenAI / Ollama sentinel -- the next call to finish()
+ // will assemble whatever the decoder accumulated.
+ return;
+ }
+ try {
+ decoder.consume(payload, listener);
+ } catch (Throwable t) {
+ failWith(t);
+ }
+ }
+
+ private void completeWith(final ChatResponse r) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ if (!result.isDone()) {
+ result.complete(r);
+ }
+ }
+ });
+ }
+
+ private void failWith(final Throwable t) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ if (listener != null) {
+ try {
+ listener.onError(t);
+ } catch (Throwable ignore) {
+ // Listener errors are swallowed so the
+ // AsyncResource.error below still fires.
+ Log.e(ignore);
+ }
+ }
+ if (!result.isDone()) {
+ result.error(t);
+ }
+ }
+ });
+ }
+
+ /// Invoked by the framework when an exception escapes the network
+ /// path. Convert to an `LlmNetworkException` and surface.
+ @Override
+ protected void handleException(Exception err) {
+ failWith(new LlmException(err.getMessage(), -1, null, null, err, LlmException.ErrorType.NETWORK));
+ }
+
+ /// Provider-specific SSE event decoder. Implementations are
+ /// stateful (accumulating text content and tool-call fragments)
+ /// and call back into the listener as deltas arrive.
+ interface SseDecoder {
+ /// Process one `data:` payload. Fire listener deltas as needed.
+ void consume(String dataPayload, StreamingListener listener) throws Exception;
+
+ /// Build the final aggregated [ChatResponse]. Called when the
+ /// stream closes cleanly (either after `[DONE]` or after EOF).
+ ChatResponse finish();
+
+ /// Convert an HTTP-error body into the right [LlmException]
+ /// subtype.
+ LlmException mapError(int httpStatus, String body);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/StreamingListener.java b/CodenameOne/src/com/codename1/ai/StreamingListener.java
new file mode 100644
index 0000000000..5bb48067cf
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/StreamingListener.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Callback for [LlmClient#chatStream]. Every method is invoked on the
+/// EDT; implementations can update UI directly without further
+/// marshalling. The terminal [ChatResponse] is delivered via the
+/// `AsyncResource` returned by `chatStream`, not via this listener.
+///
+/// Tool-call streaming differs by provider: OpenAI and Anthropic emit
+/// argument JSON in fragments (potentially many `onToolCallDelta`
+/// calls per tool); Gemini delivers tool calls atomically at the end
+/// (a single call with the complete `argumentsFragment` set to the
+/// full JSON). Either pattern is valid.
+public interface StreamingListener {
+ /// A chunk of assistant text. Append it to whatever text buffer
+ /// you're rendering.
+ void onContentDelta(String textDelta);
+
+ /// A tool-call fragment. `index` lets you correlate fragments
+ /// that belong to the same call when multiple tools are streamed
+ /// in parallel. `name` is non-null on the first fragment for each
+ /// call. `argumentsFragment` is the next slice of the arguments
+ /// JSON; concatenate fragments for the same `index` to reassemble.
+ /// `id` is the provider's tool-call id, present on the first
+ /// fragment.
+ void onToolCallDelta(int index, String id, String name, String argumentsFragment);
+
+ /// Token-accounting update. Most providers send this once at the
+ /// end; some send incremental counts.
+ void onUsage(Usage usage);
+
+ /// Mid-stream error (e.g. connection reset). The `AsyncResource`
+ /// returned by `chatStream` will also complete with this same
+ /// exception, so listeners can typically ignore this and react to
+ /// the resource. Implemented for parity with other SDKs.
+ void onError(Throwable t);
+
+ /// No-op default implementation. Subclass and override only what
+ /// you need.
+ class Adapter implements StreamingListener {
+ @Override
+ public void onContentDelta(String textDelta) {
+ }
+
+ @Override
+ public void onToolCallDelta(int index, String id, String name, String argumentsFragment) {
+ }
+
+ @Override
+ public void onUsage(Usage usage) {
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/TextPart.java b/CodenameOne/src/com/codename1/ai/TextPart.java
new file mode 100644
index 0000000000..45ee958d1f
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/TextPart.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// A plain-text fragment of a [ChatMessage].
+public final class TextPart extends MessagePart {
+ private final String text;
+
+ public TextPart(String text) {
+ this.text = text == null ? "" : text;
+ }
+
+ public String getText() {
+ return text;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/Tokenizer.java b/CodenameOne/src/com/codename1/ai/Tokenizer.java
new file mode 100644
index 0000000000..e322c9cb51
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/Tokenizer.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.List;
+
+/// Rough best-effort token counting. Useful for the common case of
+/// "am I likely to exceed this model's context window?" without
+/// shipping the full BPE table (cl100k_base is ~1.7 MB which is
+/// substantial for a mobile binary).
+///
+/// The rule of thumb is **1 token ~= 4 characters** of English text,
+/// which holds within ~10-15% for typical chat traffic. For non-Latin
+/// scripts the ratio is closer to 1:1, so we clamp the lower bound at
+/// the rough number of words. Apps that need exact accounting should
+/// fetch a usage value from the API response and adjust their budget.
+public final class Tokenizer {
+
+ private Tokenizer() {
+ }
+
+ /// Approximate token count for `text`.
+ public static int estimate(String text) {
+ if (text == null || text.length() == 0) {
+ return 0;
+ }
+ int characters = text.length();
+ int byChars = Math.max(1, characters / 4);
+ int words = 0;
+ boolean inWord = false;
+ for (int i = 0; i < characters; i++) {
+ char c = text.charAt(i);
+ if (Character.isWhitespace(c)) {
+ inWord = false;
+ } else if (!inWord) {
+ inWord = true;
+ words++;
+ }
+ }
+ return Math.max(byChars, words);
+ }
+
+ /// Estimate the prompt-tokens cost of an entire conversation.
+ /// Adds a small fixed overhead per message to approximate the
+ /// role / formatting tokens the provider includes.
+ public static int estimateMessages(List messages) {
+ if (messages == null || messages.isEmpty()) {
+ return 0;
+ }
+ int total = 0;
+ for (ChatMessage m : messages) {
+ total += 4; // role + framing overhead
+ total += estimate(m.getText());
+ }
+ return total + 2; // priming
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/Tool.java b/CodenameOne/src/com/codename1/ai/Tool.java
new file mode 100644
index 0000000000..720aae3d66
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/Tool.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// A function the model can call. `parametersJsonSchema` is a raw
+/// JSON-Schema string; each provider wraps it differently on the wire
+/// (OpenAI `{type:"function",function:{...}}`, Anthropic
+/// `{name,description,input_schema}`, Gemini `functionDeclarations`),
+/// but the inner schema shape is the same across all of them, so we
+/// hand it through as a string and let the provider client wrap.
+///
+/// #### Linking a tool to its executor
+///
+/// Pass an optional [ToolHandler] at construction time and the
+/// matching [ToolCall] can dispatch through it without the caller
+/// having to match names by hand:
+///
+/// ```
+/// Tool weather = new Tool(
+/// "get_weather",
+/// "Returns the current weather for a location",
+/// "{\"type\":\"object\",\"properties\":{" +
+/// "\"location\":{\"type\":\"string\"}}," +
+/// "\"required\":[\"location\"]}",
+/// argumentsJson -> {
+/// Map args = JSONParser.parseJSON(argumentsJson);
+/// return "{\"temp\":21,\"city\":\""
+/// + JSONParser.getString(args, "location") + "\"}";
+/// });
+///
+/// // Later, when the model returns a ToolCall:
+/// for (ToolCall call : response.getToolCalls()) {
+/// String resultJson = call.execute(Arrays.asList(weather));
+/// conversation.add(ChatMessage.toolResult(call.getId(), resultJson));
+/// }
+/// ```
+///
+/// The handler is optional -- a `Tool` constructed without one is a
+/// pure description for the model, and the caller can dispatch
+/// however they like via the raw [ToolCall#getName] /
+/// [ToolCall#getArgumentsJson] accessors.
+public final class Tool {
+ private final String name;
+ private final String description;
+ private final String parametersJsonSchema;
+ private final ToolHandler handler;
+
+ public Tool(String name, String description, String parametersJsonSchema) {
+ this(name, description, parametersJsonSchema, null);
+ }
+
+ public Tool(String name, String description, String parametersJsonSchema,
+ ToolHandler handler) {
+ if (name == null || name.length() == 0) {
+ throw new IllegalArgumentException("name is required");
+ }
+ this.name = name;
+ this.description = description == null ? "" : description;
+ this.parametersJsonSchema = parametersJsonSchema == null
+ ? "{\"type\":\"object\",\"properties\":{}}"
+ : parametersJsonSchema;
+ this.handler = handler;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getParametersJsonSchema() {
+ return parametersJsonSchema;
+ }
+
+ /// The optional executor wired up by the constructor. Returns
+ /// `null` for description-only tools.
+ public ToolHandler getHandler() {
+ return handler;
+ }
+
+ /// Invokes the handler with the given arguments JSON. Throws
+ /// `IllegalStateException` when no handler was registered.
+ public String invoke(String argumentsJson) throws Exception {
+ if (handler == null) {
+ throw new IllegalStateException(
+ "No handler registered for tool '" + name + "'. "
+ + "Either register a ToolHandler when constructing the Tool, "
+ + "or call ToolCall.getArgumentsJson() and dispatch by hand.");
+ }
+ return handler.invoke(argumentsJson);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ToolCall.java b/CodenameOne/src/com/codename1/ai/ToolCall.java
new file mode 100644
index 0000000000..5fddfa4394
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ToolCall.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+import java.util.List;
+
+/// A tool/function invocation produced by the model. The `id` round-
+/// trips the call back to a [ToolResultPart] so the provider can
+/// match the result to the original request. `argumentsJson` is the
+/// raw JSON string the model produced -- parse it with
+/// [com.codename1.io.JSONParser] if you need the structured fields.
+///
+/// Use [#execute(List)] to dispatch to the matching [Tool] handler
+/// from the request's tool list. See the [Tool] class javadoc for
+/// the full pattern.
+public final class ToolCall {
+ private final String id;
+ private final String name;
+ private final String argumentsJson;
+
+ public ToolCall(String id, String name, String argumentsJson) {
+ this.id = id;
+ this.name = name;
+ this.argumentsJson = argumentsJson == null ? "{}" : argumentsJson;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getArgumentsJson() {
+ return argumentsJson;
+ }
+
+ /// Finds the [Tool] whose name matches this call and invokes its
+ /// [ToolHandler] with this call's `argumentsJson`. Returns the
+ /// JSON result the handler produced. Apps typically wrap that in
+ /// a [ChatMessage#toolResult] and append it to the conversation
+ /// before the next chat turn.
+ ///
+ /// Throws `IllegalArgumentException` when no tool in `tools`
+ /// has a matching name, or `IllegalStateException` when the
+ /// matching tool has no handler registered.
+ public String execute(List tools) throws Exception {
+ Tool match = findTool(tools);
+ if (match == null) {
+ throw new IllegalArgumentException(
+ "No tool registered with name '" + name + "'. "
+ + "Add it to the request's tool list, or dispatch by hand "
+ + "via getName() / getArgumentsJson().");
+ }
+ return match.invoke(argumentsJson);
+ }
+
+ /// Looks up the matching [Tool] without invoking it. Useful when
+ /// the caller wants to dispatch by hand but still benefit from
+ /// the name-matching plumbing.
+ public Tool findTool(List tools) {
+ if (tools == null) {
+ return null;
+ }
+ for (Tool t : tools) {
+ if (t.getName().equals(name)) {
+ return t;
+ }
+ }
+ return null;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ToolChoice.java b/CodenameOne/src/com/codename1/ai/ToolChoice.java
new file mode 100644
index 0000000000..018e17c454
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ToolChoice.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Controls how aggressively the model will call tools. Use the
+/// constants for the three common modes; use [#named(String)] to force
+/// the model to call a specific tool.
+public final class ToolChoice {
+ /// Model picks freely between calling tools and replying with text.
+ public static final ToolChoice AUTO = new ToolChoice("auto", null);
+
+ /// Model must not call any tool -- it must reply with text.
+ public static final ToolChoice NONE = new ToolChoice("none", null);
+
+ /// Model must call exactly one tool (any tool). Useful for forcing
+ /// a structured-output path.
+ public static final ToolChoice REQUIRED = new ToolChoice("required", null);
+
+ private final String mode;
+ private final String forcedToolName;
+
+ private ToolChoice(String mode, String forcedToolName) {
+ this.mode = mode;
+ this.forcedToolName = forcedToolName;
+ }
+
+ /// Forces the model to call the named tool.
+ public static ToolChoice named(String toolName) {
+ if (toolName == null || toolName.length() == 0) {
+ throw new IllegalArgumentException("toolName is required");
+ }
+ return new ToolChoice("named", toolName);
+ }
+
+ public String getMode() {
+ return mode;
+ }
+
+ public String getForcedToolName() {
+ return forcedToolName;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/ToolHandler.java b/CodenameOne/src/com/codename1/ai/ToolHandler.java
new file mode 100644
index 0000000000..b783c9d8d4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ToolHandler.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Executor backing a [Tool]. The handler receives the raw JSON
+/// argument string the model produced and returns the JSON result
+/// to send back to the model in a [ToolResultPart]. Implementations
+/// are responsible for parsing `argumentsJson` (typically with
+/// [com.codename1.io.JSONParser]) and for serializing the result.
+///
+/// Pair with [Tool] via the four-argument constructor, then call
+/// [ToolCall#execute(java.util.List)] to dispatch.
+public interface ToolHandler {
+ /// Invokes the tool. Throw any exception to propagate it back
+ /// through [ToolCall#execute] -- the calling code can decide
+ /// whether to surface the error to the model as a tool result
+ /// or to abort the chat.
+ String invoke(String argumentsJson) throws Exception;
+}
diff --git a/CodenameOne/src/com/codename1/ai/ToolResultPart.java b/CodenameOne/src/com/codename1/ai/ToolResultPart.java
new file mode 100644
index 0000000000..0ea7ea02a7
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/ToolResultPart.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// The result of a tool invocation, sent back to the model so it can
+/// continue reasoning. Pairs with the originating [ToolCall] via the
+/// `toolCallId`. The carrying [ChatMessage] should use [Role#TOOL].
+public final class ToolResultPart extends MessagePart {
+ private final String toolCallId;
+ private final String resultJson;
+
+ /// `resultJson` is the literal JSON string the tool produced. If
+ /// the tool result isn't valid JSON, wrap it like
+ /// `"{\"text\":\"...\"}"` -- the providers expect JSON-shaped values.
+ public ToolResultPart(String toolCallId, String resultJson) {
+ this.toolCallId = toolCallId;
+ this.resultJson = resultJson == null ? "" : resultJson;
+ }
+
+ public String getToolCallId() {
+ return toolCallId;
+ }
+
+ public String getResultJson() {
+ return resultJson;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/Usage.java b/CodenameOne/src/com/codename1/ai/Usage.java
new file mode 100644
index 0000000000..e7cf089ea1
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/Usage.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2012, 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.ai;
+
+/// Token accounting returned by the provider. Any field that the
+/// provider didn't return is `-1`.
+public final class Usage {
+ private final int promptTokens;
+ private final int completionTokens;
+ private final int totalTokens;
+
+ public Usage(int promptTokens, int completionTokens, int totalTokens) {
+ this.promptTokens = promptTokens;
+ this.completionTokens = completionTokens;
+ this.totalTokens = totalTokens;
+ }
+
+ public int getPromptTokens() {
+ return promptTokens;
+ }
+
+ public int getCompletionTokens() {
+ return completionTokens;
+ }
+
+ public int getTotalTokens() {
+ return totalTokens;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/ai/package-info.java b/CodenameOne/src/com/codename1/ai/package-info.java
new file mode 100644
index 0000000000..2dc2a7feb3
--- /dev/null
+++ b/CodenameOne/src/com/codename1/ai/package-info.java
@@ -0,0 +1,80 @@
+/*
+ Document : package
+ Created on : May 24, 2026
+ Author : Shai Almog
+*/
+
+/// Codename One's AI / LLM client surface plus the value types,
+/// streaming primitives, and chat-binding helpers that sit on top
+/// of it.
+///
+/// `com.codename1.ai.LlmClient` provides a provider-agnostic chat /
+/// embeddings / image-generation API. Four static factories pick the
+/// backend; the rest of the surface is shared across all of them:
+///
+/// ```java
+/// LlmClient gpt = LlmClient.openai(SecureStorage.getInstance().get("openai_key"));
+/// LlmClient claude = LlmClient.anthropic(key);
+/// LlmClient gemini = LlmClient.gemini(key);
+/// LlmClient ollama = LlmClient.ollama(); // localhost:11434
+/// LlmClient local = LlmClient.localOpenAiCompatible(
+/// "http://10.0.0.5:8080/v1", "", "qwen2.5-7b");
+/// ```
+///
+/// All calls return [com.codename1.util.AsyncResource] so they
+/// compose naturally with the rest of the framework. Streaming chat
+/// fires per-token deltas via a [StreamingListener] *and* completes
+/// the returned resource with the aggregated `ChatResponse` once the
+/// stream closes; cancelling the resource kills the underlying
+/// socket.
+///
+/// #### Error handling
+///
+/// Every failure surfaces as a single [LlmException] whose
+/// [LlmException#getType] returns one of the [LlmException.ErrorType]
+/// enum values (`AUTH`, `RATE_LIMIT`, `INVALID_REQUEST`,
+/// `CONTEXT_LENGTH`, `MODEL_OVERLOADED`, `SERVER`, `NETWORK`,
+/// `UNKNOWN`). The recommended idiom is one `catch` + `switch`:
+///
+/// ```java
+/// try {
+/// ChatResponse r = client.chat(req).get();
+/// // ...
+/// } catch (AsyncExecutionException ae) {
+/// if (ae.getCause() instanceof LlmException) {
+/// LlmException e = (LlmException) ae.getCause();
+/// switch (e.getType()) {
+/// case RATE_LIMIT: scheduleRetry(e.getRetryAfterSeconds()); break;
+/// case AUTH: showLoginScreen(); break;
+/// case CONTEXT_LENGTH: trimHistory(); break;
+/// default: showError(e);
+/// }
+/// }
+/// }
+/// ```
+///
+/// #### Tools / function calling
+///
+/// Construct a [Tool] with an optional [ToolHandler] and pass it via
+/// [ChatRequest.Builder#tools]; when the model emits a [ToolCall]
+/// the caller invokes [ToolCall#execute(java.util.List)] to dispatch
+/// to the matching handler and feed the JSON result back as a
+/// [ToolResultPart] on the next turn.
+///
+/// #### ChatView integration
+///
+/// `com.codename1.components.ChatView` is a backend-agnostic
+/// messaging UI. Use [LlmChatBinding#bind] to wire it to an
+/// `LlmClient` in one call; for peer-to-peer chats (e.g. a WhatsApp
+/// clone) attach an `ActionListener` directly to the view's
+/// `setOnSend(...)` and stream peer responses through
+/// `view.addMessage(ChatMessage.assistant(text))`.
+///
+/// #### Image generation
+///
+/// `ImageGenerator.openai(key)` returns DALL-E results as a
+/// `com.codename1.ui.Image`. `ImageGenerator.onDevice()` resolves
+/// against the optional `cn1-ai-stablediffusion` cn1lib when
+/// present; absent that cn1lib's native bridge it completes with an
+/// `LlmException`.
+package com.codename1.ai;
diff --git a/CodenameOne/src/com/codename1/components/ChatBubble.java b/CodenameOne/src/com/codename1/components/ChatBubble.java
new file mode 100644
index 0000000000..a924077df4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/components/ChatBubble.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2012, 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.components;
+
+import com.codename1.ai.ChatMessage;
+import com.codename1.ai.Role;
+import com.codename1.ui.Container;
+import com.codename1.ui.Display;
+import com.codename1.ui.TextArea;
+import com.codename1.ui.layouts.BorderLayout;
+
+/// One row in a [ChatView]. Renders a [ChatMessage] as a styled
+/// container holding a `TextArea` for the body text. Defaults the
+/// UIID based on the message [Role]: `ChatBubbleUser`,
+/// `ChatBubbleAssistant`, `ChatBubbleSystem`.
+///
+/// The body `TextArea` is non-editable and uses native scrolling
+/// behaviour off; it wraps within the bubble. Apps that want richer
+/// rendering (markdown, code blocks) can subclass and override
+/// [#renderBody] without rewriting the wrapper.
+public class ChatBubble extends Container {
+ private final TextArea body;
+ private final ChatMessage message;
+
+ public ChatBubble(ChatMessage message) {
+ super(new BorderLayout());
+ this.message = message;
+ setUIID(defaultUiidFor(message.getRole()));
+ this.body = new TextArea(message.getText());
+ body.setEditable(false);
+ body.setUIID("ChatBubbleText");
+ body.setGrowByContent(true);
+ body.setActAsLabel(true);
+ body.getAllStyles().setBgTransparency(0);
+ add(BorderLayout.CENTER, body);
+ }
+
+ /// Replace the bubble's body text and re-render. Safe to call
+ /// from any thread; the actual mutation is marshalled to the
+ /// EDT.
+ public void setText(final String text) {
+ if (Display.getInstance().isEdt()) {
+ applyText(text);
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ applyText(text);
+ }
+ });
+ }
+
+ private void applyText(String text) {
+ body.setText(text == null ? "" : text);
+ revalidateLater();
+ }
+
+ /// Append a token-sized delta to the bubble's body. Used by
+ /// [ChatView#appendToLastMessage] during LLM streaming.
+ public void appendText(final String delta) {
+ if (delta == null || delta.length() == 0) {
+ return;
+ }
+ if (Display.getInstance().isEdt()) {
+ applyText(body.getText() + delta);
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ applyText(body.getText() + delta);
+ }
+ });
+ }
+
+ public ChatMessage getMessage() {
+ return message;
+ }
+
+ public String getBubbleText() {
+ return body.getText();
+ }
+
+ /// Returns the inner `TextArea` for styling tweaks beyond the
+ /// UIID hooks (e.g. setting a custom font).
+ protected TextArea getBody() {
+ return body;
+ }
+
+ private static String defaultUiidFor(Role role) {
+ if (role == Role.USER) {
+ return "ChatBubbleUser";
+ }
+ if (role == Role.ASSISTANT) {
+ return "ChatBubbleAssistant";
+ }
+ if (role == Role.SYSTEM) {
+ return "ChatBubbleSystem";
+ }
+ return "ChatBubble";
+ }
+
+ /// Subclass hook for custom rendering of the body. Default
+ /// behaviour is to keep the inner TextArea in sync with whatever
+ /// text has been set; override to swap in a different child
+ /// component.
+ protected void renderBody() {
+ // Default: nothing to do -- the wrapper already adds the
+ // TextArea in the constructor.
+ }
+
+ // No initComponent() override needed -- the framework consults
+ // UIManager for the bubble's UIID-driven styles during the
+ // default attach lifecycle.
+}
diff --git a/CodenameOne/src/com/codename1/components/ChatInput.java b/CodenameOne/src/com/codename1/components/ChatInput.java
new file mode 100644
index 0000000000..44e918a1a7
--- /dev/null
+++ b/CodenameOne/src/com/codename1/components/ChatInput.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2012, 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.components;
+
+import com.codename1.ui.Button;
+import com.codename1.ui.Container;
+import com.codename1.ui.TextField;
+import com.codename1.ui.events.ActionEvent;
+import com.codename1.ui.events.ActionListener;
+import com.codename1.ui.layouts.BorderLayout;
+import com.codename1.ui.layouts.BoxLayout;
+
+/// The input strip at the bottom of a [ChatView]. Contains an
+/// optional attach button, a single-line text field, an optional
+/// voice button, and a send button.
+///
+/// Each button is exposed via a setter ([#setOnAttach],
+/// [#setOnVoice], [#setOnSend]) and visible only when its listener
+/// is non-null. Listeners receive an [ActionEvent] whose `source`
+/// is this [ChatInput].
+///
+/// Default UIIDs: `ChatInput` on the container, `ChatInputField` on
+/// the text field, `ChatSendButton` / `ChatAttachButton` /
+/// `ChatVoiceButton` on the respective buttons.
+public class ChatInput extends Container {
+ private final TextField field;
+ private final Button send;
+ private final Button attach;
+ private final Button voice;
+
+ private ActionListener onSend;
+ private ActionListener onAttach;
+ private ActionListener onVoice;
+
+ public ChatInput() {
+ super(new BorderLayout());
+ setUIID("ChatInput");
+ field = new TextField();
+ field.setUIID("ChatInputField");
+ field.setSingleLineTextArea(false);
+ field.setHint("Message");
+ send = new Button("Send");
+ send.setUIID("ChatSendButton");
+ send.setVisible(false);
+ attach = new Button("+");
+ attach.setUIID("ChatAttachButton");
+ attach.setVisible(false);
+ voice = new Button("Mic");
+ voice.setUIID("ChatVoiceButton");
+ voice.setVisible(false);
+
+ // Pressing Enter (or "Done" on a mobile soft keyboard) acts
+ // like tapping Send.
+ field.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ fireSendIfNonEmpty();
+ }
+ });
+ send.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ fireSendIfNonEmpty();
+ }
+ });
+ attach.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ if (onAttach != null) {
+ onAttach.actionPerformed(new ActionEvent(ChatInput.this));
+ }
+ }
+ });
+ voice.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent evt) {
+ if (onVoice != null) {
+ onVoice.actionPerformed(new ActionEvent(ChatInput.this));
+ }
+ }
+ });
+
+ Container right = new Container(new BoxLayout(BoxLayout.X_AXIS));
+ right.add(voice);
+ right.add(send);
+ add(BorderLayout.WEST, attach);
+ add(BorderLayout.CENTER, field);
+ add(BorderLayout.EAST, right);
+ }
+
+ public TextField getField() {
+ return field;
+ }
+
+ public String getText() {
+ return field.getText() == null ? "" : field.getText();
+ }
+
+ public void setText(String text) {
+ field.setText(text);
+ }
+
+ public void clear() {
+ field.setText("");
+ }
+
+ public ChatInput setOnSend(ActionListener listener) {
+ this.onSend = listener;
+ send.setVisible(listener != null);
+ return this;
+ }
+
+ public ChatInput setOnAttach(ActionListener listener) {
+ this.onAttach = listener;
+ attach.setVisible(listener != null);
+ return this;
+ }
+
+ public ChatInput setOnVoice(ActionListener listener) {
+ this.onVoice = listener;
+ voice.setVisible(listener != null);
+ return this;
+ }
+
+ public Button getSendButton() {
+ return send;
+ }
+
+ public Button getAttachButton() {
+ return attach;
+ }
+
+ public Button getVoiceButton() {
+ return voice;
+ }
+
+ private void fireSendIfNonEmpty() {
+ String t = getText();
+ if (t.length() == 0 || onSend == null) {
+ return;
+ }
+ onSend.actionPerformed(new ActionEvent(this));
+ }
+}
diff --git a/CodenameOne/src/com/codename1/components/ChatView.java b/CodenameOne/src/com/codename1/components/ChatView.java
new file mode 100644
index 0000000000..6057514285
--- /dev/null
+++ b/CodenameOne/src/com/codename1/components/ChatView.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright (c) 2012, 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.components;
+
+import com.codename1.ai.ChatMessage;
+import com.codename1.ai.MessagePart;
+import com.codename1.ai.Role;
+import com.codename1.ai.TextPart;
+import com.codename1.ui.Container;
+import com.codename1.ui.Display;
+import com.codename1.ui.Label;
+import com.codename1.ui.events.ActionListener;
+import com.codename1.ui.layouts.BorderLayout;
+import com.codename1.ui.layouts.BoxLayout;
+import com.codename1.ui.layouts.FlowLayout;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/// A scrollable, theme-aware chat surface.
+///
+/// `ChatView` is a **pure UI component** -- it has no knowledge of
+/// LLMs or any specific chat backend. It accepts [ChatMessage]
+/// instances (the same data envelope the AI client uses, but
+/// equally usable for peer-to-peer messaging) and renders them as
+/// styled bubbles. Routing the user's input to a backend and
+/// piping responses back into the view is the caller's job. For
+/// LLM use there's a ready-made binding -- see
+/// `com.codename1.ai.LlmChatBinding` -- but the same surface
+/// trivially supports a WhatsApp-style peer chat:
+///
+/// ```
+/// ChatView view = new ChatView();
+/// view.setOnSend(evt -> {
+/// String text = view.getInput().getText();
+/// view.getInput().clear();
+/// view.addMessage(ChatMessage.user(text)); // outgoing
+/// chatService.send(text, peerReply -> {
+/// // peer responses render as "assistant" bubbles
+/// view.addMessage(ChatMessage.assistant(peerReply));
+/// });
+/// });
+/// // System / "X joined the chat" notices use ChatMessage.system.
+/// ```
+///
+/// The `USER` / `ASSISTANT` / `SYSTEM` roles map naturally to
+/// self / peer / system-notice in a peer chat; rename via CSS
+/// (the bubble UIIDs are `ChatBubbleUser`, `ChatBubbleAssistant`,
+/// `ChatBubbleSystem`) if the visual treatment needs to differ.
+/// Apps that need a totally different message data type can
+/// subclass and override [#createBubble(ChatMessage)] to render
+/// however they like while still using `ChatMessage` as the
+/// transport.
+///
+/// #### Default UIIDs
+///
+/// `ChatView`, `ChatViewMessages`, `ChatBubbleUser`,
+/// `ChatBubbleAssistant`, `ChatBubbleSystem`, `ChatBubbleText`,
+/// `ChatTypingIndicator`, `ChatInput`, `ChatInputField`,
+/// `ChatSendButton`, `ChatAttachButton`, `ChatVoiceButton`.
+public class ChatView extends Container {
+ private final Container messages;
+ private final ChatInput input;
+ private final Label typing;
+ private final List history = new ArrayList();
+ private final List bubbles = new ArrayList();
+
+ public ChatView() {
+ super(new BorderLayout());
+ setUIID("ChatView");
+ messages = new Container(new BoxLayout(BoxLayout.Y_AXIS));
+ messages.setUIID("ChatViewMessages");
+ messages.setScrollableY(true);
+ typing = new Label("...");
+ typing.setUIID("ChatTypingIndicator");
+ typing.setVisible(false);
+ input = new ChatInput();
+
+ Container bottom = new Container(new BoxLayout(BoxLayout.Y_AXIS));
+ bottom.add(typing);
+ bottom.add(input);
+
+ add(BorderLayout.CENTER, messages);
+ add(BorderLayout.SOUTH, bottom);
+ }
+
+ /// Renders `message` as a new [ChatBubble] at the bottom of the
+ /// list and scrolls into view. Safe to call from any thread.
+ ///
+ /// The bubble itself is laid out inside a single-row FlowLayout
+ /// container whose alignment is driven by the message role:
+ /// USER -> right, ASSISTANT -> left, SYSTEM -> centre. That keeps
+ /// the bubble's *visible width* anchored to its text content
+ /// rather than stretching across the full chat surface, which is
+ /// what makes a chat bubble actually look like a bubble.
+ public ChatBubble addMessage(final ChatMessage message) {
+ final ChatBubble[] out = new ChatBubble[1];
+ Runnable r = new Runnable() {
+ @Override
+ public void run() {
+ ChatBubble b = createBubble(message);
+ Container row = new Container(new FlowLayout(alignmentFor(message.getRole())));
+ row.setUIID("ChatBubbleRow");
+ row.add(b);
+ history.add(message);
+ bubbles.add(b);
+ messages.add(row);
+ messages.revalidateLater();
+ messages.scrollComponentToVisible(b);
+ out[0] = b;
+ }
+ };
+ if (Display.getInstance().isEdt()) {
+ r.run();
+ } else {
+ // Wait for the EDT to insert so we can return the bubble
+ // synchronously to the caller.
+ Display.getInstance().callSeriallyAndWait(r);
+ }
+ return out[0];
+ }
+
+ /// Appends an empty assistant/peer bubble that subsequent
+ /// [ChatBubble#appendText] calls (or [#appendToLastMessage]) can
+ /// stream tokens into. Returns the bubble so the caller can
+ /// retain the reference -- the typical pattern is to capture it
+ /// before opening a streaming network call.
+ public ChatBubble beginAssistantStream() {
+ return addMessage(new ChatMessage(Role.ASSISTANT,
+ Arrays.asList(new TextPart(""))));
+ }
+
+ /// Append a streaming token delta to the most recently added
+ /// bubble. Safe to call off-EDT. No-op when there is no
+ /// bubble yet.
+ public void appendToLastMessage(String delta) {
+ if (bubbles.isEmpty()) {
+ return;
+ }
+ bubbles.get(bubbles.size() - 1).appendText(delta);
+ }
+
+ public void setTypingIndicatorVisible(final boolean v) {
+ Runnable r = new Runnable() {
+ @Override
+ public void run() {
+ typing.setVisible(v);
+ typing.getParent().revalidateLater();
+ }
+ };
+ if (Display.getInstance().isEdt()) {
+ r.run();
+ } else {
+ Display.getInstance().callSerially(r);
+ }
+ }
+
+ public void setOnSend(ActionListener listener) {
+ input.setOnSend(listener);
+ }
+
+ public void setOnAttach(ActionListener listener) {
+ input.setOnAttach(listener);
+ }
+
+ public void setOnVoice(ActionListener listener) {
+ input.setOnVoice(listener);
+ }
+
+ public ChatInput getInput() {
+ return input;
+ }
+
+ public List getHistory() {
+ return Collections.unmodifiableList(history);
+ }
+
+ /// Override to swap in a custom bubble renderer (e.g. one that
+ /// understands markdown, shows avatars, or formats peer
+ /// messages differently from LLM responses). Default delegates
+ /// to [ChatBubble].
+ protected ChatBubble createBubble(ChatMessage message) {
+ return new ChatBubble(message);
+ }
+
+ private static int alignmentFor(Role role) {
+ if (role == Role.USER) {
+ return com.codename1.ui.Component.RIGHT;
+ }
+ if (role == Role.SYSTEM) {
+ return com.codename1.ui.Component.CENTER;
+ }
+ return com.codename1.ui.Component.LEFT;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index f78ed769c1..14abb372a5 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -1525,6 +1525,63 @@ public boolean isTranslationSupported() {
return false;
}
+ // -----------------------------------------------------------------
+ // Speech recognition + Text-to-speech hooks
+ //
+ // Default to no-op so existing platform ports compile unchanged.
+ // iOS / Android / JavaSE override these in their own impl classes.
+ // -----------------------------------------------------------------
+
+ public boolean speechRecognitionIsSupported() {
+ return false;
+ }
+
+ public void startSpeechRecognition(com.codename1.media.RecognitionOptions options,
+ com.codename1.media.RecognitionCallback callback) {
+ if (callback != null) {
+ Display.getInstance().callSerially(
+ new UnsupportedSpeechFallback(callback));
+ }
+ }
+
+ /// Static helper that fires the no-op fallback error on the EDT.
+ /// Named so SpotBugs' SIC_INNER_SHOULD_BE_STATIC_ANON doesn't
+ /// flag the equivalent anonymous Runnable.
+ private static final class UnsupportedSpeechFallback implements Runnable {
+ private final com.codename1.media.RecognitionCallback callback;
+
+ UnsupportedSpeechFallback(com.codename1.media.RecognitionCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void run() {
+ callback.onError(new UnsupportedOperationException(
+ "Speech recognition is not supported on this platform"));
+ }
+ }
+
+ public void stopSpeechRecognition() {
+ // No-op: platforms with no recognizer have nothing to stop.
+ }
+
+ public boolean textToSpeechIsSupported() {
+ return false;
+ }
+
+ public void textToSpeechSpeak(String text, com.codename1.media.TtsOptions options) {
+ // No-op fallback: apps can probe textToSpeechIsSupported()
+ // first; calling speak() on an unsupported platform is silent
+ // by design so simulator/test code paths keep flowing.
+ }
+
+ public void textToSpeechStop() {
+ }
+
+ public String[] textToSpeechAvailableVoices() {
+ return new String[0];
+ }
+
/// Translates the X/Y location for drawing on the underlying surface. Translation
/// is incremental so the new value will be added to the current translation and
/// in order to reset translation we have to invoke
diff --git a/CodenameOne/src/com/codename1/io/JSONParser.java b/CodenameOne/src/com/codename1/io/JSONParser.java
index 83820903d9..da887f03d9 100644
--- a/CodenameOne/src/com/codename1/io/JSONParser.java
+++ b/CodenameOne/src/com/codename1/io/JSONParser.java
@@ -25,7 +25,9 @@
import com.codename1.processing.Result;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
+import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Hashtable;
@@ -736,6 +738,269 @@ public static String mapToJson(Map map) {
return Result.fromContent(map).toString();
}
+ /// Convenience: parse a JSON object from a UTF-8 byte array. The
+ /// `parseJSON(Reader)` overload is the canonical entry point but
+ /// callers that already have bytes in hand don't need to wire up
+ /// a `ByteArrayInputStream` + `InputStreamReader` themselves.
+ public static Map parseJSON(byte[] bytes) throws IOException {
+ if (bytes == null || bytes.length == 0) {
+ return null;
+ }
+ InputStreamReader r = new InputStreamReader(
+ new ByteArrayInputStream(bytes), "UTF-8");
+ try {
+ return new JSONParser().parseJSON(r);
+ } finally {
+ try {
+ r.close();
+ } catch (IOException ignored) {
+ Log.e(ignored);
+ }
+ }
+ }
+
+ /// Convenience: parse a JSON object from an in-memory String.
+ public static Map parseJSON(String json) throws IOException {
+ if (json == null || json.length() == 0) {
+ return null;
+ }
+ return parseJSON(json.getBytes("UTF-8"));
+ }
+
+ /// Wrapper that lets a caller embed a pre-built JSON fragment into
+ /// a Map/List tree being serialized by [#toJson(Object)]. The
+ /// fragment is inlined verbatim rather than escaped as a string
+ /// literal. Useful when a value (such as a JSON-Schema for a tool
+ /// parameter) is already in canonical JSON form.
+ ///
+ /// ```
+ /// Map root = new HashMap();
+ /// root.put("parameters", JSONParser.rawJson(schema));
+ /// String body = JSONParser.toJson(root);
+ /// ```
+ public static final class RawJson {
+ private final String json;
+
+ private RawJson(String json) {
+ this.json = json;
+ }
+
+ public String getJson() {
+ return json;
+ }
+ }
+
+ /// Creates a [RawJson] marker. See the class javadoc for usage.
+ public static RawJson rawJson(String json) {
+ return new RawJson(json);
+ }
+
+ /// Serializes a `Map`/`List`/`String`/`Number`/`Boolean`/`null` /
+ /// [RawJson] tree to JSON. Differs from [#mapToJson(Map)] in three
+ /// ways: it takes any root type (not just `Map`); it omits
+ /// null-valued `Map` entries (rather than emitting `"key":null`,
+ /// which many REST APIs reject); and it understands the [RawJson]
+ /// sentinel so callers can splice pre-built fragments into the
+ /// tree without re-escaping them.
+ ///
+ /// `Map` keys must be `String`. Floats and doubles serialize
+ /// using `Double.toString` (no scientific notation for typical
+ /// values); integral types (`Integer`, `Long`, etc.) serialize as
+ /// integers.
+ public static String toJson(Object value) {
+ StringBuilder sb = new StringBuilder();
+ writeJsonValue(sb, value);
+ return sb.toString();
+ }
+
+ private static void writeJsonValue(StringBuilder sb, Object o) {
+ if (o == null) {
+ sb.append("null");
+ return;
+ }
+ if (o instanceof RawJson) {
+ String s = ((RawJson) o).getJson();
+ sb.append(s == null || s.length() == 0 ? "null" : s);
+ return;
+ }
+ if (o instanceof String) {
+ writeJsonString(sb, (String) o);
+ return;
+ }
+ if (o instanceof Boolean) {
+ sb.append(((Boolean) o).booleanValue() ? "true" : "false");
+ return;
+ }
+ if (o instanceof Number) {
+ Number n = (Number) o;
+ if (n instanceof Float || n instanceof Double) {
+ double d = n.doubleValue();
+ if (Double.isInfinite(d) || Double.isNaN(d)) {
+ sb.append("null");
+ } else {
+ sb.append(d);
+ }
+ } else {
+ sb.append(n.longValue());
+ }
+ return;
+ }
+ if (o instanceof Map) {
+ sb.append('{');
+ boolean first = true;
+ Map m = (Map) o;
+ for (Object kObj : m.keySet()) {
+ Object v = m.get(kObj);
+ if (v == null) {
+ // Null-valued entries are dropped on purpose; if
+ // a caller really needs `"k":null` on the wire,
+ // they can serialize with mapToJson(...) instead.
+ continue;
+ }
+ if (!first) {
+ sb.append(',');
+ }
+ first = false;
+ writeJsonString(sb, kObj.toString());
+ sb.append(':');
+ writeJsonValue(sb, v);
+ }
+ sb.append('}');
+ return;
+ }
+ if (o instanceof java.util.List) {
+ sb.append('[');
+ java.util.List l = (java.util.List) o;
+ for (int i = 0; i < l.size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ writeJsonValue(sb, l.get(i));
+ }
+ sb.append(']');
+ return;
+ }
+ writeJsonString(sb, o.toString());
+ }
+
+ private static void writeJsonString(StringBuilder sb, String s) {
+ sb.append('"');
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ sb.append("\\\"");
+ break;
+ case '\\':
+ sb.append("\\\\");
+ break;
+ case '\b':
+ sb.append("\\b");
+ break;
+ case '\f':
+ sb.append("\\f");
+ break;
+ case '\n':
+ sb.append("\\n");
+ break;
+ case '\r':
+ sb.append("\\r");
+ break;
+ case '\t':
+ sb.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ sb.append("\\u");
+ String h = Integer.toHexString(c);
+ for (int p = h.length(); p < 4; p++) {
+ sb.append('0');
+ }
+ sb.append(h);
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ sb.append('"');
+ }
+
+ /// Cast helper for the `Hashtable`/`Vector`-style return values
+ /// from [#parseJSON(java.io.Reader)]. Returns `null` when the
+ /// input is `null`; callers wishing to walk a JSON object tree
+ /// without sprinkling `(Map)` casts everywhere can pass each
+ /// nested value through this.
+ @SuppressWarnings("unchecked")
+ public static Map asMap(Object o) {
+ if (o == null) {
+ return null;
+ }
+ return (Map) o;
+ }
+
+ /// Cast helper for list-typed values pulled out of a parsed JSON
+ /// tree. Same null behavior as [#asMap].
+ @SuppressWarnings("unchecked")
+ public static java.util.List asList(Object o) {
+ if (o == null) {
+ return null;
+ }
+ return (java.util.List) o;
+ }
+
+ /// Retrieves a String field from a parsed JSON object. Returns
+ /// `null` if the key is missing or the map is `null`; calls
+ /// `toString()` on non-String values for resilience.
+ public static String getString(Map m, String key) {
+ if (m == null) {
+ return null;
+ }
+ Object v = m.get(key);
+ return v == null ? null : v.toString();
+ }
+
+ /// Retrieves an int field from a parsed JSON object, with a
+ /// fallback when the key is missing, the map is `null`, or the
+ /// value cannot be parsed as an integer.
+ public static int getInt(Map m, String key, int defaultValue) {
+ if (m == null) {
+ return defaultValue;
+ }
+ Object v = m.get(key);
+ if (v == null) {
+ return defaultValue;
+ }
+ if (v instanceof Number) {
+ return ((Number) v).intValue();
+ }
+ try {
+ return Integer.parseInt(v.toString());
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ }
+
+ /// Retrieves a double field from a parsed JSON object, with a
+ /// fallback when the key is missing, the map is `null`, or the
+ /// value cannot be parsed as a number.
+ public static double getDouble(Map m, String key, double defaultValue) {
+ if (m == null) {
+ return defaultValue;
+ }
+ Object v = m.get(key);
+ if (v == null) {
+ return defaultValue;
+ }
+ if (v instanceof Number) {
+ return ((Number) v).doubleValue();
+ }
+ try {
+ return Double.parseDouble(v.toString());
+ } catch (NumberFormatException nfe) {
+ return defaultValue;
+ }
+ }
+
/// Checks to see if this parser generates long objects and not just doubles for numeric values.
public boolean isUseLongsInstance() {
return useLongs;
diff --git a/CodenameOne/src/com/codename1/media/RecognitionCallback.java b/CodenameOne/src/com/codename1/media/RecognitionCallback.java
new file mode 100644
index 0000000000..eabc2564ef
--- /dev/null
+++ b/CodenameOne/src/com/codename1/media/RecognitionCallback.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2012, 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.media;
+
+/// Listener for [SpeechRecognizer] results. Every method is invoked
+/// on the EDT.
+public interface RecognitionCallback {
+ /// Best-effort transcript while the user is still speaking. May
+ /// fire many times before [#onResult]. Skip overriding if
+ /// `RecognitionOptions.setPartialResults(false)` was passed.
+ void onPartialResult(String transcript);
+
+ /// Final transcript for a single utterance. `confidence` is in
+ /// `[0.0, 1.0]` when the platform supplies one, or `-1` when it
+ /// doesn't. `alternatives` may be empty.
+ void onResult(String transcript, float confidence, String[] alternatives);
+
+ /// Recognition session ended (timeout, mic released, or
+ /// `SpeechRecognizer.stop()`). No more callbacks will fire.
+ void onEnd();
+
+ /// Recognition failed (no permission, no network for online
+ /// engines, hardware error).
+ void onError(Throwable t);
+
+ /// No-op adapter. Subclass and override only what you need.
+ class Adapter implements RecognitionCallback {
+ @Override
+ public void onPartialResult(String transcript) {
+ }
+
+ @Override
+ public void onResult(String transcript, float confidence, String[] alternatives) {
+ }
+
+ @Override
+ public void onEnd() {
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/media/RecognitionOptions.java b/CodenameOne/src/com/codename1/media/RecognitionOptions.java
new file mode 100644
index 0000000000..bd38f12911
--- /dev/null
+++ b/CodenameOne/src/com/codename1/media/RecognitionOptions.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2012, 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.media;
+
+/// Tuning knobs for [SpeechRecognizer]. All fields are optional; the
+/// platform picks sensible defaults for anything left unset.
+public final class RecognitionOptions {
+ private String languageTag = "en-US";
+ private boolean partialResults = true;
+ private boolean continuous = false;
+ private int maxResults = 1;
+
+ /// BCP-47 language tag (e.g. `"en-US"`, `"de-DE"`, `"fr-CA"`).
+ /// Defaults to `"en-US"`.
+ public RecognitionOptions setLanguageTag(String tag) {
+ this.languageTag = tag;
+ return this;
+ }
+
+ public String getLanguageTag() {
+ return languageTag;
+ }
+
+ /// Whether the callback should receive partial transcripts as the
+ /// user speaks. Defaults to `true`.
+ public RecognitionOptions setPartialResults(boolean partial) {
+ this.partialResults = partial;
+ return this;
+ }
+
+ public boolean isPartialResults() {
+ return partialResults;
+ }
+
+ /// Whether recognition should keep listening across silences.
+ /// Defaults to `false` (single-utterance).
+ public RecognitionOptions setContinuous(boolean c) {
+ this.continuous = c;
+ return this;
+ }
+
+ public boolean isContinuous() {
+ return continuous;
+ }
+
+ /// Maximum alternative transcripts requested per final result.
+ /// iOS supports up to 10; Android up to ~5 depending on the
+ /// vendor. Defaults to 1.
+ public RecognitionOptions setMaxResults(int n) {
+ this.maxResults = Math.max(1, n);
+ return this;
+ }
+
+ public int getMaxResults() {
+ return maxResults;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/media/SpeechRecognizer.java b/CodenameOne/src/com/codename1/media/SpeechRecognizer.java
new file mode 100644
index 0000000000..07f64cee5d
--- /dev/null
+++ b/CodenameOne/src/com/codename1/media/SpeechRecognizer.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2012, 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.media;
+
+import com.codename1.ui.Display;
+
+/// On-device speech-to-text.
+///
+/// - **iOS** -- backed by `SFSpeechRecognizer`. The first call
+/// prompts the user for microphone + speech-recognition permission
+/// (the latter requires `NSSpeechRecognitionUsageDescription` in
+/// Info.plist, which the build server injects automatically when
+/// this class is referenced).
+/// - **Android** -- backed by `android.speech.SpeechRecognizer`. May
+/// use Google's cloud speech endpoint on older devices; on Pixel
+/// and modern flagships it runs fully on-device.
+/// - **JavaSE simulator** -- no built-in implementation. Add
+/// `cn1-ai-whisper` to enable on-device transcription via
+/// whisper.cpp, or expect [#isSupported] to return false.
+///
+/// ```
+/// if (SpeechRecognizer.isSupported()) {
+/// SpeechRecognizer.recognizeOnce(new RecognitionCallback.Adapter() {
+/// public void onResult(String t, float c, String[] a) {
+/// form.findTextField().setText(t);
+/// }
+/// });
+/// }
+/// ```
+public final class SpeechRecognizer {
+
+ private SpeechRecognizer() {
+ }
+
+ /// True when the current platform implements on-device or
+ /// platform-bundled speech recognition. Even when true, the user
+ /// may still deny permission at runtime.
+ public static boolean isSupported() {
+ return Display.getInstance().isSpeechRecognitionSupported();
+ }
+
+ /// Captures one utterance with default options
+ /// ([RecognitionOptions] `en-US`, partial results on, max 1
+ /// alternative). Convenience wrapper around [#recognize].
+ public static void recognizeOnce(RecognitionCallback callback) {
+ recognize(new RecognitionOptions(), callback);
+ }
+
+ /// Starts a recognition session. Use
+ /// [RecognitionOptions#setContinuous(boolean)] to keep listening
+ /// across silences. Call [#stop()] to end a continuous session.
+ public static void recognize(RecognitionOptions options, RecognitionCallback callback) {
+ if (options == null) {
+ options = new RecognitionOptions();
+ }
+ if (callback == null) {
+ throw new IllegalArgumentException("callback is required");
+ }
+ Display.getInstance().startSpeechRecognition(options, callback);
+ }
+
+ /// Stops the active recognition session, if any. No-op when none.
+ public static void stop() {
+ Display.getInstance().stopSpeechRecognition();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/media/TextToSpeech.java b/CodenameOne/src/com/codename1/media/TextToSpeech.java
new file mode 100644
index 0000000000..c7ba6dd420
--- /dev/null
+++ b/CodenameOne/src/com/codename1/media/TextToSpeech.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2012, 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.media;
+
+import com.codename1.ui.Display;
+
+/// Speaks text aloud using the platform's built-in synthesizer.
+///
+/// - **iOS** -- `AVSpeechSynthesizer`.
+/// - **Android** -- `android.speech.tts.TextToSpeech`.
+/// - **JavaSE simulator** -- best-effort: `say` on macOS, `espeak`
+/// on Linux, SAPI via PowerShell on Windows. When none of those
+/// tools are present, [#isSupported] returns false and [#speak]
+/// silently no-ops so simulator code paths keep working.
+///
+/// No permissions or Info.plist entries are required.
+///
+/// ```
+/// TextToSpeech.speak("Welcome to the demo");
+/// ```
+public final class TextToSpeech {
+
+ private TextToSpeech() {
+ }
+
+ public static boolean isSupported() {
+ return Display.getInstance().isTextToSpeechSupported();
+ }
+
+ /// Speaks `text` using the platform default voice. Returns
+ /// immediately; the utterance plays asynchronously.
+ public static void speak(String text) {
+ speak(text, null);
+ }
+
+ public static void speak(String text, TtsOptions options) {
+ if (text == null || text.length() == 0) {
+ return;
+ }
+ Display.getInstance().textToSpeechSpeak(text,
+ options == null ? new TtsOptions() : options);
+ }
+
+ /// Stops any ongoing utterance. No-op when nothing is playing.
+ public static void stop() {
+ Display.getInstance().textToSpeechStop();
+ }
+
+ /// Returns the platform-supplied voice identifiers. May be empty
+ /// on platforms that don't enumerate voices (e.g. the simulator
+ /// when relying on the system `say` binary).
+ public static String[] getAvailableVoices() {
+ return Display.getInstance().textToSpeechAvailableVoices();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/media/TtsOptions.java b/CodenameOne/src/com/codename1/media/TtsOptions.java
new file mode 100644
index 0000000000..ab45112427
--- /dev/null
+++ b/CodenameOne/src/com/codename1/media/TtsOptions.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2012, 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.media;
+
+/// Tuning knobs for [TextToSpeech#speak(String, TtsOptions)].
+public final class TtsOptions {
+ private String languageTag;
+ private String voiceId;
+ private float rate = 1.0f;
+ private float pitch = 1.0f;
+ private float volume = 1.0f;
+
+ /// BCP-47 language tag (`"en-US"`, `"ja-JP"` etc.). When null,
+ /// the device's default voice is used.
+ public TtsOptions setLanguageTag(String tag) {
+ this.languageTag = tag;
+ return this;
+ }
+
+ public String getLanguageTag() {
+ return languageTag;
+ }
+
+ /// Platform-specific voice identifier obtained from
+ /// [TextToSpeech#getAvailableVoices()]. When null the default
+ /// voice for the language tag is used.
+ public TtsOptions setVoiceId(String id) {
+ this.voiceId = id;
+ return this;
+ }
+
+ public String getVoiceId() {
+ return voiceId;
+ }
+
+ /// Speaking rate. `1.0` is the platform default; `0.5` is half
+ /// speed; `2.0` is double. Clamped per-platform.
+ public TtsOptions setRate(float r) {
+ this.rate = r;
+ return this;
+ }
+
+ public float getRate() {
+ return rate;
+ }
+
+ /// Pitch multiplier. `1.0` is the platform default. Clamped
+ /// per-platform.
+ public TtsOptions setPitch(float p) {
+ this.pitch = p;
+ return this;
+ }
+
+ public float getPitch() {
+ return pitch;
+ }
+
+ /// Output volume in `[0.0, 1.0]`.
+ public TtsOptions setVolume(float v) {
+ this.volume = v;
+ return this;
+ }
+
+ public float getVolume() {
+ return volume;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/security/SecureStorage.java b/CodenameOne/src/com/codename1/security/SecureStorage.java
index 6b3d2ff1aa..91cc9416d8 100644
--- a/CodenameOne/src/com/codename1/security/SecureStorage.java
+++ b/CodenameOne/src/com/codename1/security/SecureStorage.java
@@ -124,4 +124,55 @@ public AsyncResource remove(String reason, String account) {
public void setKeychainAccessGroup(String group) {
// No-op fallback.
}
+
+ // -----------------------------------------------------------------
+ // Non-prompting (no-biometric) storage
+ // -----------------------------------------------------------------
+ //
+ // The methods above all gate reads on biometric authentication.
+ // That is the right contract for refresh tokens and other things
+ // the user actively unlocks, but it is the wrong contract for
+ // secrets that the app needs to read on every network call --
+ // notably LLM API keys, where prompting at chat-call time would
+ // be unusable.
+ //
+ // The single-argument overloads below provide a quieter store
+ // that the platform persists with the OS's strong-but-non-
+ // interactive secrets backend:
+ //
+ // - iOS: keychain with `kSecAttrAccessibleAfterFirstUnlock`, no
+ // `SecAccessControl`. Entries survive app updates and OS
+ // reboots; they are extracted only after the user unlocks the
+ // device at least once after each reboot.
+ // - Android: `EncryptedSharedPreferences` (Tink-backed AES-GCM)
+ // without `setUserAuthenticationRequired(true)`. No biometric
+ // prompt.
+ // - JavaSE simulator: `java.util.prefs.Preferences` encrypted
+ // with an AES key derived from the OS user account. Useful
+ // for round-tripping `LlmClient.openai(SecureStorage.getInstance().get("openai_key"))`
+ // during simulator runs without storing the key in plaintext
+ // in the project tree.
+ // - All other platforms: the base class returns null / false so
+ // the call is observable but harmless. Treat `null` from
+ // `get(account)` the same way you'd treat "not configured".
+
+ /// Quietly stores or overwrites an entry under `account`. The
+ /// user is not prompted. Returns `false` on the fallback base
+ /// class.
+ public boolean set(String account, String value) {
+ return false;
+ }
+
+ /// Quietly retrieves a previously-stored entry. Returns `null`
+ /// when the entry does not exist or when the platform does not
+ /// provide non-prompting storage.
+ public String get(String account) {
+ return null;
+ }
+
+ /// Quietly removes an entry. Returns `false` on the fallback
+ /// base class.
+ public boolean remove(String account) {
+ return false;
+ }
}
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 85637188ef..5f424f09ea 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -5296,6 +5296,44 @@ public Media createMediaRecorder(String path, String mimeType) throws IOExceptio
return impl.createMediaRecorder(path, mimeType);
}
+ /// Whether [com.codename1.media.SpeechRecognizer] is implemented
+ /// on the current platform. The user may still deny mic / speech
+ /// permission at call time even when this returns true.
+ public boolean isSpeechRecognitionSupported() {
+ return impl.speechRecognitionIsSupported();
+ }
+
+ /// Begins a speech-recognition session. See
+ /// [com.codename1.media.SpeechRecognizer#recognize] for the
+ /// callable surface; this hook is the direct delegation point
+ /// that platform ports override.
+ public void startSpeechRecognition(com.codename1.media.RecognitionOptions options,
+ com.codename1.media.RecognitionCallback callback) {
+ impl.startSpeechRecognition(options, callback);
+ }
+
+ public void stopSpeechRecognition() {
+ impl.stopSpeechRecognition();
+ }
+
+ /// Whether [com.codename1.media.TextToSpeech] is implemented on
+ /// the current platform.
+ public boolean isTextToSpeechSupported() {
+ return impl.textToSpeechIsSupported();
+ }
+
+ public void textToSpeechSpeak(String text, com.codename1.media.TtsOptions options) {
+ impl.textToSpeechSpeak(text, options);
+ }
+
+ public void textToSpeechStop() {
+ impl.textToSpeechStop();
+ }
+
+ public String[] textToSpeechAvailableVoices() {
+ return impl.textToSpeechAvailableVoices();
+ }
+
/// Returns the image IO instance that allows scaling image files.
///
/// #### Returns
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index fcec559d41..38f7f31a7c 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -2770,9 +2770,161 @@ public boolean isMinimized() {
}
}
-
-
-
+ // -----------------------------------------------------------------
+ // Simulator AI helpers: Ollama detection + best-effort TTS via OS
+ // command. These run only on JavaSE; mobile platforms get proper
+ // native impls in their own ports.
+ // -----------------------------------------------------------------
+
+ private static volatile boolean cn1AiOllamaProbeStarted;
+
+ /// Fires a quick TCP probe at the loopback Ollama port. Sets the
+ /// `cn1.ai.ollamaDetected` system property to `"true"` when the
+ /// server is reachable so [com.codename1.ai.LlmClient]'s
+ /// simulator-redirect can route there automatically.
+ private static void probeOllamaAsync() {
+ if (cn1AiOllamaProbeStarted) {
+ return;
+ }
+ cn1AiOllamaProbeStarted = true;
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ java.net.Socket s = null;
+ try {
+ s = new java.net.Socket();
+ s.connect(new java.net.InetSocketAddress("127.0.0.1", 11434), 250);
+ System.setProperty("cn1.ai.ollamaDetected", "true");
+ com.codename1.io.Log.p("Ollama detected at localhost:11434 -- "
+ + "set cn1.ai.simulatorRedirect=ollama to route LlmClient calls locally.");
+ } catch (Throwable ignored) {
+ // Not running; that's the normal case.
+ } finally {
+ if (s != null) {
+ try { s.close(); } catch (Throwable ignored) {}
+ }
+ }
+ }
+ }, "cn1-ai-ollama-probe");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ @Override
+ public boolean textToSpeechIsSupported() {
+ // On macOS the `say` binary ships with the OS. On Linux we
+ // require `espeak`/`espeak-ng` to be installed. On Windows
+ // we shell out to PowerShell's System.Speech (XP+). Detect
+ // lazily on first call so startup cost stays at zero for
+ // apps that never use TTS.
+ String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
+ if (os.contains("mac")) {
+ return true;
+ }
+ if (os.contains("win")) {
+ return true;
+ }
+ if (os.contains("linux") || os.contains("nix") || os.contains("nux")) {
+ return probeBinary("espeak") || probeBinary("espeak-ng");
+ }
+ return false;
+ }
+
+ @Override
+ public void textToSpeechSpeak(final String text, final com.codename1.media.TtsOptions options) {
+ if (text == null || text.length() == 0) {
+ return;
+ }
+ Thread t = new Thread(new Runnable() {
+ public void run() {
+ String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
+ java.util.List cmd = new java.util.ArrayList();
+ if (os.contains("mac")) {
+ cmd.add("say");
+ if (options != null && options.getVoiceId() != null) {
+ cmd.add("-v");
+ cmd.add(options.getVoiceId());
+ }
+ cmd.add(text);
+ } else if (os.contains("win")) {
+ String escaped = text.replace("'", "''");
+ cmd.add("powershell");
+ cmd.add("-Command");
+ cmd.add("Add-Type -AssemblyName System.Speech; "
+ + "(New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('"
+ + escaped + "')");
+ } else {
+ cmd.add(probeBinary("espeak-ng") ? "espeak-ng" : "espeak");
+ cmd.add(text);
+ }
+ try {
+ new ProcessBuilder(cmd).inheritIO().start().waitFor();
+ } catch (Throwable err) {
+ com.codename1.io.Log.p("TTS failed: " + err.getMessage());
+ }
+ }
+ }, "cn1-tts");
+ t.setDaemon(true);
+ t.start();
+ }
+
+ @Override
+ public void textToSpeechStop() {
+ // Best-effort: kill any active `say` / `espeak`. On Windows
+ // we can't reach the spawned PowerShell process easily; for
+ // most desktop workflows that's acceptable since utterances
+ // are typically short.
+ String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
+ try {
+ if (os.contains("mac")) {
+ new ProcessBuilder("killall", "say").redirectErrorStream(true).start();
+ } else if (os.contains("linux") || os.contains("nix") || os.contains("nux")) {
+ new ProcessBuilder("pkill", "-x", "espeak").redirectErrorStream(true).start();
+ new ProcessBuilder("pkill", "-x", "espeak-ng").redirectErrorStream(true).start();
+ }
+ } catch (Throwable ignored) {
+ }
+ }
+
+ @Override
+ public String[] textToSpeechAvailableVoices() {
+ String os = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
+ if (!os.contains("mac")) {
+ // `say -v ?` is the only one of the three platforms that
+ // exposes a structured voice list. Linux espeak's list
+ // is voluminous and not portable; Windows PowerShell
+ // SAPI list query is slow. Return empty rather than
+ // pretending.
+ return new String[0];
+ }
+ try {
+ Process p = new ProcessBuilder("say", "-v", "?").redirectErrorStream(true).start();
+ java.io.BufferedReader r = new java.io.BufferedReader(
+ new java.io.InputStreamReader(p.getInputStream(), "UTF-8"));
+ java.util.List voices = new java.util.ArrayList();
+ String line;
+ while ((line = r.readLine()) != null) {
+ int spaceIdx = line.indexOf(' ');
+ if (spaceIdx > 0) {
+ voices.add(line.substring(0, spaceIdx));
+ }
+ }
+ p.waitFor();
+ return voices.toArray(new String[voices.size()]);
+ } catch (Throwable t) {
+ return new String[0];
+ }
+ }
+
+ private static boolean probeBinary(String name) {
+ try {
+ Process p = new ProcessBuilder("which", name).redirectErrorStream(true).start();
+ p.waitFor();
+ return p.exitValue() == 0;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
private void loadSkinFile(InputStream skin, final JFrame frm) {
try {
ZipInputStream z = new ZipInputStream(skin);
@@ -6553,6 +6705,10 @@ private void loadSkinFile(String f, JFrame frm) {
public void init(Object m) {
inInit = true;
+ // Fire-and-forget probe so LlmClient.simulatorRedirect=auto
+ // can detect a local Ollama install without blocking startup.
+ probeOllamaAsync();
+
/* File updater = new File(System.getProperty("user.home") + File.separator + ".codenameone" + File.separator + "UpdateCodenameOne.jar");
if(!updater.exists()) {
System.out.println("******************************************************************************");
diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res
index 24e035cc17..03b1e85216 100644
Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ
diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res
index a5ad76267f..619fda6e54 100644
Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ
diff --git a/maven/cn1-ai-mlkit-barcode/android/pom.xml b/maven/cn1-ai-mlkit-barcode/android/pom.xml
new file mode 100644
index 0000000000..85ac121467
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/android/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-android
+ jar
+
+
+ 1.8
+ 1.8
+
+
+
+ src/main/dummy
+
+ src/main/java
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-common
+ ${project.version}
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/android/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java b/maven/cn1-ai-mlkit-barcode/android/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java
new file mode 100644
index 0000000000..b9143cd3b2
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/android/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java
@@ -0,0 +1,38 @@
+package com.codename1.ai.mlkit.barcode;
+
+
+public class NativeBarcodeScannerImpl {
+ public String[] scan(byte[] imageBytes) {
+ android.graphics.Bitmap bm = android.graphics.BitmapFactory.decodeByteArray(
+ imageBytes, 0, imageBytes.length);
+ if (bm == null) return new String[0];
+ com.google.mlkit.vision.common.InputImage img =
+ com.google.mlkit.vision.common.InputImage.fromBitmap(bm, 0);
+ com.google.mlkit.vision.barcode.BarcodeScannerOptions o =
+ new com.google.mlkit.vision.barcode.BarcodeScannerOptions.Builder().build();
+ com.google.mlkit.vision.barcode.BarcodeScanner scanner =
+ com.google.mlkit.vision.barcode.BarcodeScanning.getClient(o);
+ final java.util.List out = new java.util.ArrayList();
+ final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
+ scanner.process(img)
+ .addOnSuccessListener(new com.google.android.gms.tasks.OnSuccessListener<
+ java.util.List>() {
+ public void onSuccess(java.util.List rs) {
+ for (com.google.mlkit.vision.barcode.common.Barcode b : rs) {
+ String v = b.getRawValue();
+ if (v != null) out.add(v);
+ }
+ latch.countDown();
+ }
+ })
+ .addOnFailureListener(new com.google.android.gms.tasks.OnFailureListener() {
+ public void onFailure(Exception e) { latch.countDown(); }
+ });
+ try { latch.await(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
+ return out.toArray(new String[0]);
+ }
+
+ public boolean isSupported() {
+ return true;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-barcode/common/codenameone_library_appended.properties b/maven/cn1-ai-mlkit-barcode/common/codenameone_library_appended.properties
new file mode 100644
index 0000000000..f47bb32ba0
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/codenameone_library_appended.properties
@@ -0,0 +1 @@
+# Reserved for build hints appended to the consuming app's properties.
diff --git a/maven/cn1-ai-mlkit-barcode/common/codenameone_library_required.properties b/maven/cn1-ai-mlkit-barcode/common/codenameone_library_required.properties
new file mode 100644
index 0000000000..62c8e803db
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/codenameone_library_required.properties
@@ -0,0 +1,7 @@
+# Auto-installed build hints for cn1-ai-mlkit-barcode.
+# Loaded by the Codename One build server when this cn1lib is in the
+# project classpath. The build-time AiDependencyTable scanner adds
+# further per-class entries as needed.
+codename1.arg.ios.pods=GoogleMLKit/BarcodeScanning
+codename1.arg.android.gradleDep=implementation 'com.google.mlkit:barcode-scanning:17.2.0'
+codename1.arg.android.xpermissions=
diff --git a/maven/cn1-ai-mlkit-barcode/common/pom.xml b/maven/cn1-ai-mlkit-barcode/common/pom.xml
new file mode 100644
index 0000000000..d985ec956b
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-common
+ jar
+
+
+ UTF-8
+ 1.8
+ 1.8
+
+
+
+
+ com.codenameone
+ codenameone-core
+ ${project.version}
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ com.codenameone
+ codenameone-maven-plugin
+ ${project.version}
+
+
+ build-legacy-cn1lib
+ package
+
+ cn1lib
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ copy-library-required-properties
+ process-resources
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/BarcodeScanner.java b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/BarcodeScanner.java
new file mode 100644
index 0000000000..64724c6cc0
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/BarcodeScanner.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.barcode;
+
+import com.codename1.ai.LlmException;
+import com.codename1.system.NativeLookup;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+/// ML Kit Barcode Scanning.
+///
+/// Decodes barcodes (QR, EAN, UPC, Data Matrix, PDF417, etc.) from images.
+/// Bridges to `MLKitBarcodeScanning` on iOS and
+/// `com.google.mlkit:barcode-scanning` on Android.
+///
+public final class BarcodeScanner {
+ private BarcodeScanner() { }
+
+ /// True only when the running platform has a native bridge wired up.
+ public static boolean isSupported() {
+ NativeBarcodeScanner bridge = NativeLookup.create(NativeBarcodeScanner.class);
+ return bridge != null && bridge.isSupported();
+ }
+
+ public static AsyncResource scan(final byte[] imageBytes) {
+ final AsyncResource out = new AsyncResource();
+ if (imageBytes == null || imageBytes.length == 0) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() { out.complete(new String[0]); }
+ });
+ return out;
+ }
+ final NativeBarcodeScanner bridge = NativeLookup.create(NativeBarcodeScanner.class);
+ if (bridge == null || !bridge.isSupported()) {
+ out.error(new LlmException("BarcodeScanner.scan is not supported on this platform.",
+ -1, null, null, null, LlmException.ErrorType.UNKNOWN));
+ return out;
+ }
+ Display.getInstance().scheduleBackgroundTask(new Runnable() {
+ @Override public void run() {
+ try {
+ final String[] r = bridge.scan(imageBytes);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() { out.complete(r == null ? new String[0] : r); }
+ });
+ } catch (final Throwable t) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() {
+ out.error(new LlmException("BarcodeScanner.scan failed: " + t.getMessage(),
+ -1, null, null, t, LlmException.ErrorType.UNKNOWN));
+ }
+ });
+ }
+ }
+ });
+ return out;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScanner.java b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScanner.java
new file mode 100644
index 0000000000..84cafb7dee
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScanner.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.barcode;
+
+import com.codename1.system.NativeInterface;
+
+/// Native bridge for [BarcodeScanner]. iOS, Android, and JavaSE implementations
+/// live in their respective port modules under this cn1lib.
+public interface NativeBarcodeScanner extends NativeInterface {
+ String[] scan(byte[] imageBytes);
+}
diff --git a/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/package-info.java b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/package-info.java
new file mode 100644
index 0000000000..573acffadf
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/src/main/java/com/codename1/ai/mlkit/barcode/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2012, 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.
+ */
+
+/// ML Kit Barcode Scanning.
+///
+/// Decodes barcodes (QR, EAN, UPC, Data Matrix, PDF417, etc.) from images.
+/// Bridges to `MLKitBarcodeScanning` on iOS and
+/// `com.google.mlkit:barcode-scanning` on Android.
+///
+/// The single public class in this package is [BarcodeScanner], which exposes
+/// the feature via static methods returning
+/// [com.codename1.util.AsyncResource]. A package-private
+/// `NativeBarcodeScanner` interface holds the platform contract; iOS Obj-C and
+/// Android Java implementations live in `nativeios.zip` / `nativeand.zip`
+/// inside the cn1lib bundle. References to `BarcodeScanner.*` are recognised
+/// by the Codename One build server's `AiDependencyTable`, which
+/// auto-injects the matching CocoaPod / Swift Package / Android Gradle
+/// dep / `Info.plist` usage strings / Android permissions on every
+/// build -- no manual build hints required.
+package com.codename1.ai.mlkit.barcode;
diff --git a/maven/cn1-ai-mlkit-barcode/common/src/test/java/com/codename1/ai/mlkit/barcode/BarcodeScannerTest.java b/maven/cn1-ai-mlkit-barcode/common/src/test/java/com/codename1/ai/mlkit/barcode/BarcodeScannerTest.java
new file mode 100644
index 0000000000..aad5a6bf8d
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/common/src/test/java/com/codename1/ai/mlkit/barcode/BarcodeScannerTest.java
@@ -0,0 +1,29 @@
+package com.codename1.ai.mlkit.barcode;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class BarcodeScannerTest {
+
+ /** Mock implementation of NativeBarcodeScanner for headless JVM tests. */
+ static class MockBridge implements NativeBarcodeScanner {
+ boolean supported = true;
+ public boolean isSupported() { return supported; }
+ public String[] scan(byte[] imageBytes) {
+ return new String[]{"x", "y"};
+ }
+ }
+
+ @Test
+ void mock_bridge_returns_two_codes() {
+ MockBridge b = new MockBridge();
+ String[] r = b.scan(new byte[]{1, 2, 3});
+ assertEquals(2, r.length);
+ assertEquals("x", r[0]);
+ }
+
+ @Test
+ void bridge_reports_supported() {
+ assertTrue(new MockBridge().isSupported());
+ }
+}
diff --git a/maven/cn1-ai-mlkit-barcode/ios/pom.xml b/maven/cn1-ai-mlkit-barcode/ios/pom.xml
new file mode 100644
index 0000000000..45d32f7069
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/ios/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-ios
+ jar
+
+
+ src/main/dummy
+
+ src/main/objectivec
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-common
+ ${project.version}
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.h b/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.h
new file mode 100644
index 0000000000..aa55f016d5
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.h
@@ -0,0 +1,8 @@
+#import
+
+@interface com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl : NSObject {
+}
+
+-(NSData*)scan:(NSData*)param;
+-(BOOL)isSupported;
+@end
diff --git a/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.m b/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.m
new file mode 100644
index 0000000000..2c93b7eb6e
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/ios/src/main/objectivec/com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.m
@@ -0,0 +1,48 @@
+#import "com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl.h"
+#import
+#import
+#import
+#import
+
+@implementation com_codename1_ai_mlkit_barcode_NativeBarcodeScannerImpl
+
+-(NSData*)scan:(NSData*)param {
+ UIImage *image = [UIImage imageWithData:param];
+ if (!image) return [self packStrings:@[]];
+ MLKVisionImage *vision = [[MLKVisionImage alloc] initWithImage:image];
+ MLKBarcodeScannerOptions *opts = [[MLKBarcodeScannerOptions alloc] init];
+ MLKBarcodeScanner *scanner = [MLKBarcodeScanner barcodeScannerWithOptions:opts];
+ __block NSArray *values = @[];
+ dispatch_semaphore_t sem = dispatch_semaphore_create(0);
+ [scanner processImage:vision completion:^(NSArray * _Nullable barcodes,
+ NSError * _Nullable error) {
+ NSMutableArray *m = [NSMutableArray array];
+ for (MLKBarcode *b in barcodes ?: @[]) {
+ if (b.rawValue) [m addObject:b.rawValue];
+ }
+ values = m;
+ dispatch_semaphore_signal(sem);
+ }];
+ dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
+ return [self packStrings:values];
+}
+
+-(NSData*)packStrings:(NSArray *)strings {
+ // Encode as length-prefixed UTF-8 (network byte order int + bytes).
+ NSMutableData *out = [NSMutableData data];
+ uint32_t count = htonl((uint32_t)strings.count);
+ [out appendBytes:&count length:sizeof(count)];
+ for (NSString *s in strings) {
+ NSData *u = [s dataUsingEncoding:NSUTF8StringEncoding];
+ uint32_t len = htonl((uint32_t)u.length);
+ [out appendBytes:&len length:sizeof(len)];
+ [out appendData:u];
+ }
+ return out;
+}
+
+-(BOOL)isSupported{
+ return YES;
+}
+
+@end
diff --git a/maven/cn1-ai-mlkit-barcode/javascript/pom.xml b/maven/cn1-ai-mlkit-barcode/javascript/pom.xml
new file mode 100644
index 0000000000..41d6d6fe57
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/javascript/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-javascript
+ jar
+
+
+ src/main/dummy
+
+ src/main/javascript
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-common
+ ${project.version}
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/javascript/src/main/javascript/com_codename1_ai_mlkit_barcode_NativeBarcodeScanner.js b/maven/cn1-ai-mlkit-barcode/javascript/src/main/javascript/com_codename1_ai_mlkit_barcode_NativeBarcodeScanner.js
new file mode 100644
index 0000000000..0c74196d59
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/javascript/src/main/javascript/com_codename1_ai_mlkit_barcode_NativeBarcodeScanner.js
@@ -0,0 +1,15 @@
+(function(exports){
+
+var o = {};
+
+ o.scan__byte_1ARRAY = function(param1, callback) {
+ callback.error(new Error("Not implemented yet"));
+ };
+
+ o.isSupported_ = function(callback) {
+ callback.complete(false);
+ };
+
+exports.com_codename1_ai_mlkit_barcode_NativeBarcodeScanner= o;
+
+})(cn1_get_native_interfaces());
diff --git a/maven/cn1-ai-mlkit-barcode/javase/pom.xml b/maven/cn1-ai-mlkit-barcode/javase/pom.xml
new file mode 100644
index 0000000000..042c98b76c
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/javase/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-javase
+ jar
+
+
+ 1.8
+ 1.8
+
+
+
+ src/main/dummy
+
+ src/main/java
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-common
+ ${project.version}
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/javase/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java b/maven/cn1-ai-mlkit-barcode/javase/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java
new file mode 100644
index 0000000000..55e1343f02
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/javase/src/main/java/com/codename1/ai/mlkit/barcode/NativeBarcodeScannerImpl.java
@@ -0,0 +1,30 @@
+package com.codename1.ai.mlkit.barcode;
+
+public class NativeBarcodeScannerImpl implements NativeBarcodeScanner {
+
+ private static boolean hintsEnsured;
+ private static synchronized void ensureSimulatorHints() {
+ if (hintsEnsured) return;
+ hintsEnsured = true;
+ java.util.Map hints =
+ com.codename1.ui.Display.getInstance().getProjectBuildHints();
+ if (hints == null) return; // not running in the simulator
+ if (!hints.containsKey("ios.NSCameraUsageDescription")) {
+ com.codename1.ui.Display.getInstance()
+ .setProjectBuildHint("ios.NSCameraUsageDescription", "This app uses the camera to scan barcodes.");
+ }
+ }
+
+ public NativeBarcodeScannerImpl() {
+ ensureSimulatorHints();
+ }
+ public String[] scan(byte[] imageBytes) {
+ if (imageBytes == null || imageBytes.length == 0) return new String[0];
+ // Deterministic stub for simulator runs.
+ return new String[]{"SIMULATOR_BARCODE_" + imageBytes.length};
+ }
+
+ public boolean isSupported() {
+ return true;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-barcode/lib/pom.xml b/maven/cn1-ai-mlkit-barcode/lib/pom.xml
new file mode 100644
index 0000000000..ea09018e06
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/lib/pom.xml
@@ -0,0 +1,78 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode-lib
+ pom
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-common
+ ${project.version}
+
+
+
+
+
+ ios
+
+ codename1.platform ios
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-ios
+ ${project.version}
+
+
+
+
+ android
+
+ codename1.platform android
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-android
+ ${project.version}
+
+
+
+
+ javase
+
+ codename1.platform javase
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-javase
+ ${project.version}
+
+
+
+
+ javascript
+
+ codename1.platform javascript
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-barcode-javascript
+ ${project.version}
+
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-barcode/pom.xml b/maven/cn1-ai-mlkit-barcode/pom.xml
new file mode 100644
index 0000000000..e1f34b84bc
--- /dev/null
+++ b/maven/cn1-ai-mlkit-barcode/pom.xml
@@ -0,0 +1,33 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ codenameone
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-barcode
+ pom
+ Codename One AI: cn1-ai-mlkit-barcode
+ ML Kit Barcode Scanning
+
+
+ cn1-ai-mlkit-barcode
+ 1.8
+ 1.8
+ 1.8
+
+
+
+ common
+ ios
+ android
+ javase
+ javascript
+ lib
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/android/pom.xml b/maven/cn1-ai-mlkit-docscan/android/pom.xml
new file mode 100644
index 0000000000..c7a0d791a8
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/android/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-android
+ jar
+
+
+ 1.8
+ 1.8
+
+
+
+ src/main/dummy
+
+ src/main/java
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-common
+ ${project.version}
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/android/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java b/maven/cn1-ai-mlkit-docscan/android/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java
new file mode 100644
index 0000000000..c29139f481
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/android/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java
@@ -0,0 +1,20 @@
+package com.codename1.ai.mlkit.docscan;
+
+
+public class NativeDocumentScannerImpl {
+ public String scanToFile(byte[] imageBytes) {
+ try {
+ java.io.File f = java.io.File.createTempFile("docscan-", ".jpg");
+ java.io.FileOutputStream fos = new java.io.FileOutputStream(f);
+ fos.write(imageBytes);
+ fos.close();
+ return f.getAbsolutePath();
+ } catch (java.io.IOException ioe) {
+ return "";
+ }
+ }
+
+ public boolean isSupported() {
+ return true;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-docscan/common/codenameone_library_appended.properties b/maven/cn1-ai-mlkit-docscan/common/codenameone_library_appended.properties
new file mode 100644
index 0000000000..f47bb32ba0
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/codenameone_library_appended.properties
@@ -0,0 +1 @@
+# Reserved for build hints appended to the consuming app's properties.
diff --git a/maven/cn1-ai-mlkit-docscan/common/codenameone_library_required.properties b/maven/cn1-ai-mlkit-docscan/common/codenameone_library_required.properties
new file mode 100644
index 0000000000..59ed129453
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/codenameone_library_required.properties
@@ -0,0 +1,6 @@
+# Auto-installed build hints for cn1-ai-mlkit-docscan.
+# Loaded by the Codename One build server when this cn1lib is in the
+# project classpath. The build-time AiDependencyTable scanner adds
+# further per-class entries as needed.
+codename1.arg.ios.add_frameworks=VisionKit
+codename1.arg.android.gradleDep=implementation 'com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1'
diff --git a/maven/cn1-ai-mlkit-docscan/common/pom.xml b/maven/cn1-ai-mlkit-docscan/common/pom.xml
new file mode 100644
index 0000000000..bd49a08b83
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-common
+ jar
+
+
+ UTF-8
+ 1.8
+ 1.8
+
+
+
+
+ com.codenameone
+ codenameone-core
+ ${project.version}
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ com.codenameone
+ codenameone-maven-plugin
+ ${project.version}
+
+
+ build-legacy-cn1lib
+ package
+
+ cn1lib
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ copy-library-required-properties
+ process-resources
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/DocumentScanner.java b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/DocumentScanner.java
new file mode 100644
index 0000000000..2eb94018e2
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/DocumentScanner.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.docscan;
+
+import com.codename1.ai.LlmException;
+import com.codename1.system.NativeLookup;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+/// ML Kit / VisionKit Document Scanner.
+///
+/// Captures and crops document photos. On iOS uses Apple's VisionKit + Core Image rectangle detection (no extra pod). On Android uses the Google Play services document-scanner module.
+///
+public final class DocumentScanner {
+ private DocumentScanner() { }
+
+ /// True only when the running platform has a native bridge wired up.
+ public static boolean isSupported() {
+ NativeDocumentScanner bridge = NativeLookup.create(NativeDocumentScanner.class);
+ return bridge != null && bridge.isSupported();
+ }
+
+ public static AsyncResource scanToFile(final byte[] imageBytes) {
+ final AsyncResource out = new AsyncResource();
+ final NativeDocumentScanner bridge = NativeLookup.create(NativeDocumentScanner.class);
+ if (bridge == null || !bridge.isSupported()) {
+ out.error(new LlmException("DocumentScanner.scanToFile is not supported on this platform.",
+ -1, null, null, null, LlmException.ErrorType.UNKNOWN));
+ return out;
+ }
+ Display.getInstance().scheduleBackgroundTask(new Runnable() {
+ @Override public void run() {
+ try {
+ final String r = bridge.scanToFile(imageBytes);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() { out.complete(r == null ? "" : r); }
+ });
+ } catch (final Throwable t) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() {
+ out.error(new LlmException("DocumentScanner.scanToFile failed: " + t.getMessage(),
+ -1, null, null, t, LlmException.ErrorType.UNKNOWN));
+ }
+ });
+ }
+ }
+ });
+ return out;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScanner.java b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScanner.java
new file mode 100644
index 0000000000..3426aa7e59
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScanner.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.docscan;
+
+import com.codename1.system.NativeInterface;
+
+/// Native bridge for [DocumentScanner]. iOS, Android, and JavaSE implementations
+/// live in their respective port modules under this cn1lib.
+public interface NativeDocumentScanner extends NativeInterface {
+ String scanToFile(byte[] imageBytes);
+}
diff --git a/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/package-info.java b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/package-info.java
new file mode 100644
index 0000000000..1dd15b5334
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/src/main/java/com/codename1/ai/mlkit/docscan/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2012, 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.
+ */
+
+/// ML Kit / VisionKit Document Scanner.
+///
+/// Captures and crops document photos. On iOS uses Apple's VisionKit + Core Image rectangle detection (no extra pod). On Android uses the Google Play services document-scanner module.
+///
+/// The single public class in this package is [DocumentScanner], which exposes
+/// the feature via static methods returning
+/// [com.codename1.util.AsyncResource]. A package-private
+/// `NativeDocumentScanner` interface holds the platform contract; iOS Obj-C and
+/// Android Java implementations live in `nativeios.zip` / `nativeand.zip`
+/// inside the cn1lib bundle. References to `DocumentScanner.*` are recognised
+/// by the Codename One build server's `AiDependencyTable`, which
+/// auto-injects the matching CocoaPod / Swift Package / Android Gradle
+/// dep / `Info.plist` usage strings / Android permissions on every
+/// build -- no manual build hints required.
+package com.codename1.ai.mlkit.docscan;
diff --git a/maven/cn1-ai-mlkit-docscan/common/src/test/java/com/codename1/ai/mlkit/docscan/DocumentScannerTest.java b/maven/cn1-ai-mlkit-docscan/common/src/test/java/com/codename1/ai/mlkit/docscan/DocumentScannerTest.java
new file mode 100644
index 0000000000..d348880c08
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/common/src/test/java/com/codename1/ai/mlkit/docscan/DocumentScannerTest.java
@@ -0,0 +1,20 @@
+package com.codename1.ai.mlkit.docscan;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class DocumentScannerTest {
+
+ /** Mock implementation of NativeDocumentScanner for headless JVM tests. */
+ static class MockBridge implements NativeDocumentScanner {
+ boolean supported = true;
+ public boolean isSupported() { return supported; }
+ public String scanToFile(byte[] imageBytes) { return "/tmp/x.jpg"; }
+ }
+
+ @Test
+ void mock_returns_path() {
+ MockBridge b = new MockBridge();
+ assertEquals("/tmp/x.jpg", b.scanToFile(new byte[]{1}));
+ }
+}
diff --git a/maven/cn1-ai-mlkit-docscan/ios/pom.xml b/maven/cn1-ai-mlkit-docscan/ios/pom.xml
new file mode 100644
index 0000000000..b5cdab01ab
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/ios/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-ios
+ jar
+
+
+ src/main/dummy
+
+ src/main/objectivec
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-common
+ ${project.version}
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.h b/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.h
new file mode 100644
index 0000000000..c68dd8802e
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.h
@@ -0,0 +1,8 @@
+#import
+
+@interface com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl : NSObject {
+}
+
+-(NSString*)scanToFile:(NSData*)param;
+-(BOOL)isSupported;
+@end
diff --git a/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.m b/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.m
new file mode 100644
index 0000000000..23ca8e045d
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/ios/src/main/objectivec/com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.m
@@ -0,0 +1,42 @@
+#import "com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl.h"
+#import
+#import
+
+@implementation com_codename1_ai_mlkit_docscan_NativeDocumentScannerImpl
+
+// VisionKit-based fallback: Apple's VNDocumentCameraViewController is
+// interactive; this bridge accepts a pre-captured image and returns its
+// cropped JPEG path. On iOS 13+ VisionKit handles the live UI flow; the
+// sample app drives that flow and feeds the bytes into the cn1lib.
+-(NSString*)scanToFile:(NSData*)param {
+ UIImage *image = [UIImage imageWithData:param];
+ if (!image) return @"";
+ CIImage *ci = [CIImage imageWithCGImage:image.CGImage];
+ CIContext *ctx = [CIContext context];
+ CIDetector *det = [CIDetector detectorOfType:CIDetectorTypeRectangle context:ctx
+ options:@{CIDetectorAccuracy: CIDetectorAccuracyHigh}];
+ NSArray *features = [det featuresInImage:ci];
+ UIImage *cropped = image;
+ if (features.count > 0) {
+ CIRectangleFeature *rf = (CIRectangleFeature *)features.firstObject;
+ CIImage *flat = [ci imageByApplyingFilter:@"CIPerspectiveCorrection" withInputParameters:@{
+ @"inputTopLeft": [CIVector vectorWithCGPoint:rf.topLeft],
+ @"inputTopRight": [CIVector vectorWithCGPoint:rf.topRight],
+ @"inputBottomLeft": [CIVector vectorWithCGPoint:rf.bottomLeft],
+ @"inputBottomRight": [CIVector vectorWithCGPoint:rf.bottomRight]
+ }];
+ CGImageRef cg = [ctx createCGImage:flat fromRect:flat.extent];
+ cropped = [UIImage imageWithCGImage:cg];
+ CGImageRelease(cg);
+ }
+ NSString *path = [NSString stringWithFormat:@"%@/docscan-%@.jpg",
+ NSTemporaryDirectory(), [[NSUUID UUID] UUIDString]];
+ [UIImageJPEGRepresentation(cropped, 0.92) writeToFile:path atomically:YES];
+ return path;
+}
+
+-(BOOL)isSupported{
+ return YES;
+}
+
+@end
diff --git a/maven/cn1-ai-mlkit-docscan/javascript/pom.xml b/maven/cn1-ai-mlkit-docscan/javascript/pom.xml
new file mode 100644
index 0000000000..d04dde9e63
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/javascript/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-javascript
+ jar
+
+
+ src/main/dummy
+
+ src/main/javascript
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-common
+ ${project.version}
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/javascript/src/main/javascript/com_codename1_ai_mlkit_docscan_NativeDocumentScanner.js b/maven/cn1-ai-mlkit-docscan/javascript/src/main/javascript/com_codename1_ai_mlkit_docscan_NativeDocumentScanner.js
new file mode 100644
index 0000000000..a57708226b
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/javascript/src/main/javascript/com_codename1_ai_mlkit_docscan_NativeDocumentScanner.js
@@ -0,0 +1,15 @@
+(function(exports){
+
+var o = {};
+
+ o.scanToFile__byte_1ARRAY = function(param1, callback) {
+ callback.error(new Error("Not implemented yet"));
+ };
+
+ o.isSupported_ = function(callback) {
+ callback.complete(false);
+ };
+
+exports.com_codename1_ai_mlkit_docscan_NativeDocumentScanner= o;
+
+})(cn1_get_native_interfaces());
diff --git a/maven/cn1-ai-mlkit-docscan/javase/pom.xml b/maven/cn1-ai-mlkit-docscan/javase/pom.xml
new file mode 100644
index 0000000000..fadb1f0479
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/javase/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-javase
+ jar
+
+
+ 1.8
+ 1.8
+
+
+
+ src/main/dummy
+
+ src/main/java
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-common
+ ${project.version}
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/javase/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java b/maven/cn1-ai-mlkit-docscan/javase/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java
new file mode 100644
index 0000000000..573928cc41
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/javase/src/main/java/com/codename1/ai/mlkit/docscan/NativeDocumentScannerImpl.java
@@ -0,0 +1,17 @@
+package com.codename1.ai.mlkit.docscan;
+
+public class NativeDocumentScannerImpl implements NativeDocumentScanner {
+ public String scanToFile(byte[] imageBytes) {
+ try {
+ java.io.File f = java.io.File.createTempFile("docscan-stub-", ".jpg");
+ java.nio.file.Files.write(f.toPath(), imageBytes);
+ return f.getAbsolutePath();
+ } catch (java.io.IOException ioe) {
+ return "";
+ }
+ }
+
+ public boolean isSupported() {
+ return true;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-docscan/lib/pom.xml b/maven/cn1-ai-mlkit-docscan/lib/pom.xml
new file mode 100644
index 0000000000..849e7db831
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/lib/pom.xml
@@ -0,0 +1,78 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan-lib
+ pom
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-common
+ ${project.version}
+
+
+
+
+
+ ios
+
+ codename1.platform ios
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-ios
+ ${project.version}
+
+
+
+
+ android
+
+ codename1.platform android
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-android
+ ${project.version}
+
+
+
+
+ javase
+
+ codename1.platform javase
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-javase
+ ${project.version}
+
+
+
+
+ javascript
+
+ codename1.platform javascript
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-docscan-javascript
+ ${project.version}
+
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-docscan/pom.xml b/maven/cn1-ai-mlkit-docscan/pom.xml
new file mode 100644
index 0000000000..4fc73d70b6
--- /dev/null
+++ b/maven/cn1-ai-mlkit-docscan/pom.xml
@@ -0,0 +1,33 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ codenameone
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-docscan
+ pom
+ Codename One AI: cn1-ai-mlkit-docscan
+ ML Kit / VisionKit Document Scanner
+
+
+ cn1-ai-mlkit-docscan
+ 1.8
+ 1.8
+ 1.8
+
+
+
+ common
+ ios
+ android
+ javase
+ javascript
+ lib
+
+
diff --git a/maven/cn1-ai-mlkit-face/android/pom.xml b/maven/cn1-ai-mlkit-face/android/pom.xml
new file mode 100644
index 0000000000..270dc8e302
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/android/pom.xml
@@ -0,0 +1,58 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-face
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-face-android
+ jar
+
+
+ 1.8
+ 1.8
+
+
+
+ src/main/dummy
+
+ src/main/java
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
+
+ com.codenameone
+ cn1-ai-mlkit-face-common
+ ${project.version}
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-face/android/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetectorImpl.java b/maven/cn1-ai-mlkit-face/android/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetectorImpl.java
new file mode 100644
index 0000000000..959577903b
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/android/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetectorImpl.java
@@ -0,0 +1,40 @@
+package com.codename1.ai.mlkit.face;
+
+
+public class NativeFaceDetectorImpl {
+ public int[] detect(byte[] imageBytes) {
+ android.graphics.Bitmap bm = android.graphics.BitmapFactory.decodeByteArray(
+ imageBytes, 0, imageBytes.length);
+ if (bm == null) return new int[0];
+ com.google.mlkit.vision.common.InputImage img =
+ com.google.mlkit.vision.common.InputImage.fromBitmap(bm, 0);
+ com.google.mlkit.vision.face.FaceDetector det =
+ com.google.mlkit.vision.face.FaceDetection.getClient(
+ new com.google.mlkit.vision.face.FaceDetectorOptions.Builder().build());
+ final java.util.List rs = new java.util.ArrayList();
+ final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
+ det.process(img)
+ .addOnSuccessListener(new com.google.android.gms.tasks.OnSuccessListener<
+ java.util.List>() {
+ public void onSuccess(java.util.List faces) {
+ for (com.google.mlkit.vision.face.Face f : faces) {
+ android.graphics.Rect r = f.getBoundingBox();
+ rs.add(new int[]{r.left, r.top, r.width(), r.height()});
+ }
+ latch.countDown();
+ }
+ })
+ .addOnFailureListener(new com.google.android.gms.tasks.OnFailureListener() {
+ public void onFailure(Exception e) { latch.countDown(); }
+ });
+ try { latch.await(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
+ int[] flat = new int[rs.size() * 4];
+ int i = 0;
+ for (int[] r : rs) { System.arraycopy(r, 0, flat, i, 4); i += 4; }
+ return flat;
+ }
+
+ public boolean isSupported() {
+ return true;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-face/common/codenameone_library_appended.properties b/maven/cn1-ai-mlkit-face/common/codenameone_library_appended.properties
new file mode 100644
index 0000000000..f47bb32ba0
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/codenameone_library_appended.properties
@@ -0,0 +1 @@
+# Reserved for build hints appended to the consuming app's properties.
diff --git a/maven/cn1-ai-mlkit-face/common/codenameone_library_required.properties b/maven/cn1-ai-mlkit-face/common/codenameone_library_required.properties
new file mode 100644
index 0000000000..7232c084e6
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/codenameone_library_required.properties
@@ -0,0 +1,6 @@
+# Auto-installed build hints for cn1-ai-mlkit-face.
+# Loaded by the Codename One build server when this cn1lib is in the
+# project classpath. The build-time AiDependencyTable scanner adds
+# further per-class entries as needed.
+codename1.arg.ios.pods=GoogleMLKit/FaceDetection
+codename1.arg.android.gradleDep=implementation 'com.google.mlkit:face-detection:16.1.5'
diff --git a/maven/cn1-ai-mlkit-face/common/pom.xml b/maven/cn1-ai-mlkit-face/common/pom.xml
new file mode 100644
index 0000000000..deb83fa155
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/pom.xml
@@ -0,0 +1,85 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-face
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-face-common
+ jar
+
+
+ UTF-8
+ 1.8
+ 1.8
+
+
+
+
+ com.codenameone
+ codenameone-core
+ ${project.version}
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+ com.codenameone
+ codenameone-maven-plugin
+ ${project.version}
+
+
+ build-legacy-cn1lib
+ package
+
+ cn1lib
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ copy-library-required-properties
+ process-resources
+
+
+
+
+
+
+
+ run
+
+
+
+
+
+
+
diff --git a/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/FaceDetector.java b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/FaceDetector.java
new file mode 100644
index 0000000000..a73e47dc29
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/FaceDetector.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.face;
+
+import com.codename1.ai.LlmException;
+import com.codename1.system.NativeLookup;
+import com.codename1.ui.Display;
+import com.codename1.util.AsyncResource;
+
+/// ML Kit Face Detection.
+///
+/// Detects faces in images and returns bounding boxes.
+/// Bridges to `MLKitFaceDetection` on iOS and
+/// `com.google.mlkit:face-detection` on Android.
+///
+public final class FaceDetector {
+ private FaceDetector() { }
+
+ /// True only when the running platform has a native bridge wired up.
+ public static boolean isSupported() {
+ NativeFaceDetector bridge = NativeLookup.create(NativeFaceDetector.class);
+ return bridge != null && bridge.isSupported();
+ }
+
+ /// Returns an array of face bounding-box quadruples
+ /// (x, y, width, height) packed as `int[4 * n]`.
+ public static AsyncResource detect(final byte[] imageBytes) {
+ final AsyncResource out = new AsyncResource();
+ final NativeFaceDetector bridge = NativeLookup.create(NativeFaceDetector.class);
+ if (bridge == null || !bridge.isSupported()) {
+ out.error(new LlmException("FaceDetector.detect is not supported on this platform.",
+ -1, null, null, null, LlmException.ErrorType.UNKNOWN));
+ return out;
+ }
+ Display.getInstance().scheduleBackgroundTask(new Runnable() {
+ @Override public void run() {
+ try {
+ final int[] r = bridge.detect(imageBytes);
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() { out.complete(r == null ? new int[0] : r); }
+ });
+ } catch (final Throwable t) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override public void run() {
+ out.error(new LlmException("FaceDetector.detect failed: " + t.getMessage(),
+ -1, null, null, t, LlmException.ErrorType.UNKNOWN));
+ }
+ });
+ }
+ }
+ });
+ return out;
+ }
+}
diff --git a/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetector.java b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetector.java
new file mode 100644
index 0000000000..2e4d8e52ea
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/NativeFaceDetector.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2012, 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.ai.mlkit.face;
+
+import com.codename1.system.NativeInterface;
+
+/// Native bridge for [FaceDetector]. iOS, Android, and JavaSE implementations
+/// live in their respective port modules under this cn1lib.
+public interface NativeFaceDetector extends NativeInterface {
+ int[] detect(byte[] imageBytes);
+}
diff --git a/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/package-info.java b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/package-info.java
new file mode 100644
index 0000000000..b6ce855ece
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/src/main/java/com/codename1/ai/mlkit/face/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2012, 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.
+ */
+
+/// ML Kit Face Detection.
+///
+/// Detects faces in images and returns bounding boxes.
+/// Bridges to `MLKitFaceDetection` on iOS and
+/// `com.google.mlkit:face-detection` on Android.
+///
+/// The single public class in this package is [FaceDetector], which exposes
+/// the feature via static methods returning
+/// [com.codename1.util.AsyncResource]. A package-private
+/// `NativeFaceDetector` interface holds the platform contract; iOS Obj-C and
+/// Android Java implementations live in `nativeios.zip` / `nativeand.zip`
+/// inside the cn1lib bundle. References to `FaceDetector.*` are recognised
+/// by the Codename One build server's `AiDependencyTable`, which
+/// auto-injects the matching CocoaPod / Swift Package / Android Gradle
+/// dep / `Info.plist` usage strings / Android permissions on every
+/// build -- no manual build hints required.
+package com.codename1.ai.mlkit.face;
diff --git a/maven/cn1-ai-mlkit-face/common/src/test/java/com/codename1/ai/mlkit/face/FaceDetectorTest.java b/maven/cn1-ai-mlkit-face/common/src/test/java/com/codename1/ai/mlkit/face/FaceDetectorTest.java
new file mode 100644
index 0000000000..718c8fdf83
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/common/src/test/java/com/codename1/ai/mlkit/face/FaceDetectorTest.java
@@ -0,0 +1,23 @@
+package com.codename1.ai.mlkit.face;
+
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class FaceDetectorTest {
+
+ /** Mock implementation of NativeFaceDetector for headless JVM tests. */
+ static class MockBridge implements NativeFaceDetector {
+ boolean supported = true;
+ public boolean isSupported() { return supported; }
+ public int[] detect(byte[] imageBytes) {
+ return new int[]{1, 2, 3, 4, 5, 6, 7, 8};
+ }
+ }
+
+ @Test
+ void mock_bridge_returns_two_faces() {
+ MockBridge b = new MockBridge();
+ int[] r = b.detect(new byte[]{1});
+ assertEquals(8, r.length);
+ }
+}
diff --git a/maven/cn1-ai-mlkit-face/ios/pom.xml b/maven/cn1-ai-mlkit-face/ios/pom.xml
new file mode 100644
index 0000000000..97fbc47673
--- /dev/null
+++ b/maven/cn1-ai-mlkit-face/ios/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+
+ com.codenameone
+ cn1-ai-mlkit-face
+ 8.0-SNAPSHOT
+
+
+ cn1-ai-mlkit-face-ios
+ jar
+
+
+ src/main/dummy
+
+ src/main/objectivec
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ package
+
+
+