diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0efe70b9db7..4381a6e8c50 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -402,7 +402,7 @@ jobs:
-Dcn1.binaries="${CN1_BINARIES}" \
-Pcompile-android \
-DbackendAnalysis \
- -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,compose-compat,fxml-compiler,desktop-compat-runtime -am verify
+ -pl android,ios,project-model,build-engine,codenameone-maven-plugin,codenameone-gradle-plugin,backend,backend-test,backend-parparvm,javac,android-res-compiler,android-compat,swing-compat,javafx-compat,compose-compat,fxml-compiler,desktop-compat-runtime,cn1-app-kit/backend,cn1-app-kit/common -am verify
- name: Type-check the SQLCipher-backed database package
if: ${{ matrix.java-version == 8 }}
env:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f7ec3a71614..97d67af4250 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -202,7 +202,8 @@ jobs:
for artifact in codenameone-core codenameone-maven-plugin codenameone-platform-feature-catalog \
codenameone-build-hint-catalog codenameone-build-hint-tools \
codenameone-cil-translator codenameone-unity-compat \
- cn1app-archetype cn1lib-archetype; do
+ cn1app-archetype cn1lib-archetype \
+ cn1-app-kit-common cn1-app-kit-backend cn1-app-kit-lib; do
url="${R2_BASE_URL}/com/codenameone/${artifact}/${GITHUB_REF_NAME}/${artifact}-${GITHUB_REF_NAME}.pom?cb=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
if [ "$code" != "200" ]; then
diff --git a/docs/developer-guide/Backend-Beans.asciidoc b/docs/developer-guide/Backend-Beans.asciidoc
index bc9e526719c..b72cb5ff1ac 100644
--- a/docs/developer-guide/Backend-Beans.asciidoc
+++ b/docs/developer-guide/Backend-Beans.asciidoc
@@ -430,6 +430,32 @@ The library itself is never wired at its own build; only the server that uses
it generates the routes and beans. When the server defines a class with the
same name as one in a library, the server's class is used.
+[[app-kit]]
+==== The app kit
+
+The app kit is a cn1lib built this way, published with Codename One under the
+same version. It holds the plumbing a client and server application usually
+needs, so a project needs no copy of its own:
+
+* On the client, `com.codename1.appkit.Net` adapts a generated client call to a
+ pair of success and failure callbacks, `Skeleton` is a loading placeholder,
+ and `Chart` draws a small bar chart with its `Meter`.
+* On the server, `com.codename1.appkit.server.Caller` reports the signed-in
+ user and their roles, `Text` validates and trims request fields, and
+ `Grants` ends a user's other sessions. `AccountSecurity` builds the password
+ encoder, the user store and the registered client a sign-in flow needs.
+ None of these is a bean of its own: the kit wires nothing into a server
+ until the server declares it, so a server that takes only `Text` needs no
+ authorization server.
+* The `push` package sends notifications through `PushSender`. It ships with a
+ sender for the push service, one that prints to the console in development,
+ one that records what was sent for tests, and one that fails with a clear
+ message when push has no configuration.
+
+A project adds `cn1-app-kit-lib` to its client dependencies and
+`cn1-app-kit-backend` to its server, both at the Codename One version it builds
+with.
+
=== What the build refuses
Everything a container would discover at start-up is checked while the module
diff --git a/maven/cn1-app-kit/backend/pom.xml b/maven/cn1-app-kit/backend/pom.xml
new file mode 100644
index 00000000000..7ccb5d56ad8
--- /dev/null
+++ b/maven/cn1-app-kit/backend/pom.xml
@@ -0,0 +1,24 @@
+
+
+ 4.0.0
+
+ com.codenameone
+ cn1-app-kit
+ 8.0-SNAPSHOT
+
+ cn1-app-kit-backend
+ jar
+ Codename One App Kit: server
+
+
+
+ com.codenameone
+ codenameone-backend
+ ${project.version}
+ provided
+
+
+
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java
new file mode 100644
index 00000000000..c8b7f08b131
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Caller.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server;
+
+import com.codename1.backend.ResponseStatusException;
+import com.codename1.backend.security.Authentication;
+import com.codename1.backend.security.GrantedAuthority;
+import com.codename1.backend.security.SecurityContextHolder;
+
+import java.util.Iterator;
+
+/// Who is making the request being served.
+///
+/// The endpoints implement interfaces generated from the shared contract, whose
+/// methods take what the app sends and nothing else, so the caller is read from
+/// the security context of the request and not from a parameter.
+public final class Caller {
+ private Caller() {
+ }
+
+ /// The signed-in user's name, which is the e-mail address of the account.
+ /// Answers 401 when nobody is signed in; the filter chain has normally done
+ /// so already, and this is what stands behind it.
+ public static String name() {
+ Authentication who = SecurityContextHolder.getContext().getAuthentication();
+ if (who == null || !who.isAuthenticated() || who.getName() == null) {
+ throw new ResponseStatusException(401, "Sign in first");
+ }
+ return who.getName();
+ }
+
+ /// Whether the caller holds `role`, named without its `ROLE_` prefix.
+ public static boolean hasRole(String role) {
+ Authentication who = SecurityContextHolder.getContext().getAuthentication();
+ if (who == null || who.getAuthorities() == null) {
+ return false;
+ }
+ String wanted = "ROLE_" + role;
+ Iterator extends GrantedAuthority> it = who.getAuthorities().iterator();
+ while (it.hasNext()) {
+ if (wanted.equals(it.next().getAuthority())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java
new file mode 100644
index 00000000000..e1800680655
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Grants.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server;
+
+import com.codename1.backend.DataSource;
+import com.codename1.backend.security.Authentication;
+import com.codename1.backend.security.SecurityContextHolder;
+import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationToken;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+/// The sign-ins an account has open, and ending them.
+///
+/// Signing in leaves a grant in the authorization server's own table, and a
+/// refresh token is good for as long as its grant is there: refreshing asks
+/// for no password. So a password changed because a phone was lost changes
+/// nothing for whoever holds that phone, unless the grants go too.
+///
+/// An access token already issued is not recalled by this. It is a signed
+/// token that is checked without the table, and it runs out on its own within
+/// its few minutes; what ends here is the means of getting another.
+///
+/// Not a component: a library's components are wired into every server that
+/// depends on it, and only a server running the authorization server has the
+/// [OAuth2AuthorizationService] this needs. Such a server declares it from a
+/// `@Bean` method.
+public class Grants {
+ private final DataSource db;
+ private final OAuth2AuthorizationService authorizations;
+
+ public Grants(DataSource db, OAuth2AuthorizationService authorizations) {
+ this.db = db;
+ this.authorizations = authorizations;
+ }
+
+ /// Ends every sign-in of an account but the one this request was made
+ /// with, so the device a password was changed on stays signed in and every
+ /// other has to give the new password.
+ public void endOthers(String username) throws IOException {
+ end(username, current());
+ }
+
+ /// Ends every sign-in of an account.
+ public void endAll(String username) throws IOException {
+ end(username, "");
+ }
+
+ /// Ends one sign-in, by the grant id [#current()] answered when it was
+ /// made. Its refresh token goes with it.
+ public void endGrant(String id) throws IOException {
+ if (id != null && id.length() > 0) {
+ authorizations.remove(id);
+ }
+ }
+
+ private void end(String username, String kept) throws IOException {
+ // The table is the authorization server's, which offers a grant by its
+ // id and no list of an account's: this reads the ids and leaves the
+ // removing, which takes the grant's tokens with it, to the service.
+ List rows = db.query("SELECT id FROM cn1_oauth2_authorization WHERE principal_name = ?",
+ new Object[] {username});
+ for (int iter = 0; rows != null && iter < rows.size(); iter++) {
+ Object row = rows.get(iter);
+ Object id = row instanceof Map ? ((Map) row).get("id") : null;
+ if (id != null && !kept.equals(String.valueOf(id))) {
+ authorizations.remove(String.valueOf(id));
+ }
+ }
+ }
+
+ /// The id of the grant the request's token was issued under, or empty for
+ /// a request that carried no such token. The server writes the grant's id
+ /// before the first dot of the token's own.
+ public static String current() {
+ Authentication who = SecurityContextHolder.getContext().getAuthentication();
+ if (!(who instanceof JwtAuthenticationToken)) {
+ return "";
+ }
+ String id = ((JwtAuthenticationToken) who).getToken().getId();
+ int dot = id == null ? -1 : id.indexOf('.');
+ return dot > 0 ? id.substring(0, dot) : "";
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java
new file mode 100644
index 00000000000..1d6a1151846
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/Text.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server;
+
+import com.codename1.backend.ResponseStatusException;
+
+/// What the app sends, made safe to store: trimmed, bounded, and one of the
+/// words a field allows.
+public final class Text {
+ private Text() {
+ }
+
+ /// `value` trimmed, or empty for null; answers 400 when longer than `max`.
+ public static String optional(String value, int max, String what) {
+ String text = value == null ? "" : value.trim();
+ if (text.length() > max) {
+ throw new ResponseStatusException(400, "That is too long for " + what);
+ }
+ return text;
+ }
+
+ /// `value` trimmed; answers 400 when it is empty or longer than `max`.
+ public static String required(String value, int max, String what) {
+ String text = optional(value, max, what);
+ if (text.length() == 0) {
+ throw new ResponseStatusException(400, "Tell us " + what);
+ }
+ return text;
+ }
+
+ /// `value` when it is one of `allowed`, and `fallback` when it is empty;
+ /// answers 400 for anything else. The words are compared as written: they
+ /// are the app's own constants, not something a person types.
+ public static String oneOf(String value, String[] allowed, String fallback, String what) {
+ String text = value == null ? "" : value.trim();
+ if (text.length() == 0) {
+ return fallback;
+ }
+ for (int iter = 0; iter < allowed.length; iter++) {
+ if (allowed[iter].equals(text)) {
+ return text;
+ }
+ }
+ throw new ResponseStatusException(400, "That is not a value for " + what);
+ }
+
+ /// The first letters of the first and last words of a name, in upper case
+ /// when they are ASCII letters: "Dana Doe" is "DD".
+ public static String initials(String name) {
+ String text = name == null ? "" : name.trim();
+ if (text.length() == 0) {
+ return "";
+ }
+ int last = text.lastIndexOf(' ');
+ StringBuilder out = new StringBuilder(2);
+ out.append(upper(text.charAt(0)));
+ if (last > 0 && last + 1 < text.length()) {
+ out.append(upper(text.charAt(last + 1)));
+ }
+ return out.toString();
+ }
+
+ private static char upper(char c) {
+ // By hand: toUpperCase() follows the server's locale.
+ return c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c;
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java
new file mode 100644
index 00000000000..b6f73a5aba0
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/CloudPushSender.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.push;
+
+import com.codename1.backend.Tasks;
+import com.codename1.backend.push.CloudPush;
+import com.codename1.backend.push.PushNotification;
+import com.codename1.backend.push.PushResult;
+import com.codename1.backend.push.PushTarget;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+// tag::appkit-push-cloud[]
+/// Sends through the Codename One push service, which delivers to Firebase,
+/// Apple, Huawei and the browsers on the app's behalf.
+///
+/// The call to the service is made on a thread of its own, so the request that
+/// changed something answers without waiting for it, and a refusal is logged and
+/// not thrown: the user still learns of the change the next time the app
+/// reads it.
+public class CloudPushSender implements PushSender {
+ private final CloudPush push;
+
+ /// @param push the client for the service, holding the app's id and the
+ /// server key
+ public CloudPushSender(CloudPush push) {
+ this.push = push;
+ }
+
+ @Override
+ public void send(List targets, final PushNotification message,
+ final Runnable delivered) {
+ if (targets == null || targets.isEmpty()) {
+ return;
+ }
+ final List to = new ArrayList(targets);
+ Tasks.platform(() -> deliver(to, message, delivered));
+ }
+
+ private void deliver(List to, PushNotification message, Runnable delivered) {
+ boolean accepted = false;
+ try {
+ PushResult result = push.send(message, to);
+ accepted = result.isAccepted();
+ if (!accepted) {
+ System.err.println("[push] the push service refused a notification: "
+ + result.getStatus() + " " + result.getError());
+ }
+ } catch (IOException | RuntimeException err) {
+ System.err.println("[push] a notification was not sent: " + err);
+ }
+ if (accepted && delivered != null) {
+ try {
+ delivered.run();
+ } catch (RuntimeException err) {
+ System.err.println("[push] a notification was sent, and what follows it failed: "
+ + err);
+ }
+ }
+ }
+}
+// end::appkit-push-cloud[]
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java
new file mode 100644
index 00000000000..b1d2102c865
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/ConsolePushSender.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.push;
+
+import com.codename1.backend.push.PushNotification;
+import com.codename1.backend.push.PushTarget;
+
+import java.util.List;
+
+/// Prints each notification on the server's console instead of sending it.
+///
+/// For development only: the application hands it
+/// out on a development profile alone.
+public class ConsolePushSender implements PushSender {
+ @Override
+ public void send(List targets, PushNotification message, Runnable delivered) {
+ if (targets == null || targets.isEmpty()) {
+ return;
+ }
+ System.out.println("[push] to " + targets.size() + " device(s): "
+ + message.toJson());
+ // Printed is as delivered as this sender gets.
+ if (delivered != null) {
+ delivered.run();
+ }
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java
new file mode 100644
index 00000000000..9b87459c075
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/MisconfiguredPushSender.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.push;
+
+import com.codename1.backend.push.PushNotification;
+import com.codename1.backend.push.PushTarget;
+
+import java.util.List;
+
+/// What the server sends with when the push settings are incomplete: nothing
+/// goes out, and the log says why.
+///
+/// The problem is logged once when the server starts and again on every send,
+/// naming the settings to fix. Unlike a text message, a notification that does
+/// not go out costs nobody a sign-in, so it is not an error to the caller.
+public class MisconfiguredPushSender implements PushSender {
+ private final String problem;
+
+ /// @param problem what is wrong, naming the settings to fix
+ public MisconfiguredPushSender(String problem) {
+ this.problem = problem;
+ System.err.println("[config] " + problem);
+ }
+
+ @Override
+ public void send(List targets, PushNotification message, Runnable delivered) {
+ if (targets != null && !targets.isEmpty()) {
+ System.err.println("[config] a push notification was not sent: " + problem);
+ }
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java
new file mode 100644
index 00000000000..1bd32e34c66
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/PushSender.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.push;
+
+import com.codename1.backend.push.PushNotification;
+import com.codename1.backend.push.PushTarget;
+
+import java.util.List;
+
+// tag::appkit-push-sender[]
+/// Sends push notifications to devices.
+///
+/// The application chooses the implementation, normally from a property, when
+/// it builds its beans. A notification is a courtesy on top of what the
+/// app reads from the server, so sending one never fails the change it is about:
+/// an implementation logs what went wrong and returns.
+public interface PushSender {
+ /// Sends `message` to every one of `targets`. Returns at once; the sending
+ /// itself may happen later, on another thread.
+ ///
+ /// @param delivered run once the push service has taken the message, on
+ /// whatever thread learnt it; never when it was refused or could not be
+ /// sent. Null when nobody needs to know.
+ void send(List targets, PushNotification message, Runnable delivered);
+}
+// end::appkit-push-sender[]
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java
new file mode 100644
index 00000000000..4f26b8125e4
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/push/RecordingPushSender.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.push;
+
+import com.codename1.backend.push.PushNotification;
+import com.codename1.backend.push.PushTarget;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/// Keeps every notification instead of sending it, for the tests to read.
+///
+/// What the `test` profile gets: a test signs a phone in, changes something, and
+/// asks this what each device was sent. Refused on any profile that is not a
+/// development one.
+public class RecordingPushSender implements PushSender {
+ private final List sent = new ArrayList();
+ private int refusals;
+ private int unconfirmed;
+
+ /// One notification as one device was sent it.
+ public static final class Sent {
+ private final String provider;
+ private final String token;
+ private final Map message;
+
+ Sent(String provider, String token, Map message) {
+ this.provider = provider;
+ this.token = token;
+ this.message = message;
+ }
+
+ public String provider() {
+ return provider;
+ }
+
+ public String token() {
+ return token;
+ }
+
+ /// The notification as it would go on the wire: `title`, `body`,
+ /// `data`, `surface` and the rest.
+ public Map message() {
+ return message;
+ }
+ }
+
+ @Override
+ public void send(List targets, PushNotification message, Runnable delivered) {
+ // Outside the lock: what follows a delivery writes to the database.
+ if (record(targets, message) && delivered != null) {
+ delivered.run();
+ }
+ }
+
+ /// Records a send, and answers whether the push service would confirm it.
+ private synchronized boolean record(List targets, PushNotification message) {
+ if (refusals > 0) {
+ refusals--;
+ throw new IllegalStateException("The push provider is unavailable");
+ }
+ if (targets != null) {
+ for (PushTarget target : targets) {
+ sent.add(new Sent(target.getProvider(), target.getToken(), message.toMap()));
+ }
+ }
+ if (unconfirmed > 0) {
+ unconfirmed--;
+ return false;
+ }
+ return true;
+ }
+
+ /// What the device with `token` was sent, oldest first.
+ public synchronized List sentTo(String token) {
+ List out = new ArrayList();
+ for (Sent each : sent) {
+ if (each.token.equals(token)) {
+ out.add(each);
+ }
+ }
+ return out;
+ }
+
+ /// Everything sent so far, oldest first.
+ public synchronized List all() {
+ return new ArrayList(sent);
+ }
+
+ public synchronized void clear() {
+ sent.clear();
+ refusals = 0;
+ unconfirmed = 0;
+ }
+
+ /// Makes the next `count` sends go out and never be confirmed, as a
+ /// service that refuses them later, on its own thread, does.
+ public synchronized void leaveUnconfirmed(int count) {
+ unconfirmed = count;
+ }
+
+ /// Makes the next `count` sends throw, as a provider that is down does.
+ public synchronized void refuse(int count) {
+ refusals = count;
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java
new file mode 100644
index 00000000000..81fffca801f
--- /dev/null
+++ b/maven/cn1-app-kit/backend/src/main/java/com/codename1/appkit/server/security/AccountSecurity.java
@@ -0,0 +1,377 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit.server.security;
+
+import com.codename1.backend.Config;
+import com.codename1.backend.DataSource;
+import com.codename1.backend.HttpServer;
+import com.codename1.backend.security.AntPathRequestMatcher;
+import com.codename1.backend.security.DisabledException;
+import com.codename1.backend.security.GrantedAuthority;
+import com.codename1.backend.security.HttpSecurity;
+import com.codename1.backend.security.HttpStatusReturningLogoutSuccessHandler;
+import com.codename1.backend.security.LogoutFilter;
+import com.codename1.backend.security.SecurityExchange;
+import com.codename1.backend.security.SecurityFilter;
+import com.codename1.backend.security.SecurityFilterChain;
+import com.codename1.backend.security.SessionCreationPolicy;
+import com.codename1.backend.security.core.userdetails.JdbcUserDetailsManager;
+import com.codename1.backend.security.core.userdetails.UserDetails;
+import com.codename1.backend.security.crypto.JwkSource;
+import com.codename1.backend.security.crypto.PasswordEncoder;
+import com.codename1.backend.security.crypto.PasswordEncoderFactories;
+import com.codename1.backend.security.oauth2.core.AuthorizationGrantType;
+import com.codename1.backend.security.oauth2.core.ClientAuthenticationMethod;
+import com.codename1.backend.security.oauth2.jwt.DefaultJwtDecoder;
+import com.codename1.backend.security.oauth2.jwt.JwtAudienceValidator;
+import com.codename1.backend.security.oauth2.jwt.JwtClaimValidator;
+import com.codename1.backend.security.oauth2.jwt.JwtIssuerValidator;
+import com.codename1.backend.security.oauth2.jwt.JwtValidators;
+import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerKeys;
+import com.codename1.backend.security.oauth2.server.authorization.AuthorizationServerSettings;
+import com.codename1.backend.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import com.codename1.backend.security.oauth2.server.authorization.JdbcRegisteredClientRepository;
+import com.codename1.backend.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import com.codename1.backend.security.oauth2.server.authorization.RegisteredClient;
+import com.codename1.backend.security.oauth2.server.authorization.RegisteredClientRepository;
+import com.codename1.backend.security.oauth2.server.resource.JwtAuthenticationConverter;
+import com.codename1.backend.security.ratelimit.InMemoryRateLimiter;
+import com.codename1.backend.security.ratelimit.RateLimitKeys;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+
+/// The sign-in half of an app's server: accounts, the app as an OAuth client,
+/// the authorization server, and how the API reads the token it issues.
+///
+/// The server is two things at once, with a filter chain for each:
+///
+/// - **An authorization server.** The app signs a user in here -- e-mail and
+/// password, then the authorization-code flow with PKCE -- and gets a
+/// short-lived access token and a refresh token. Nothing but this server ever
+/// sees the password. [#signIn] builds its chain.
+/// - **An API.** Everything under `/api` takes that access token as a bearer
+/// token, keeps no session, and decides from the account's roles what the
+/// caller may do. The application writes that chain, since its routes and
+/// roles are its own, from [#tokenDecoder] and [#accountRoles].
+///
+/// Nothing here is a bean by itself. The application's `@Configuration` returns
+/// these from its own `@Bean` methods, so it names its client and audience in
+/// one place and replaces any piece by writing that method differently.
+public final class AccountSecurity {
+ /// Where the authorization code is sent back to. The app makes the requests
+ /// of the flow itself and reads the code out of the redirect, so this is
+ /// never opened; it is the loopback address because that is the one address
+ /// nobody else can register a listener on. An app that signs in through the
+ /// system browser adds its own address scheme here with `redirectUri`.
+ public static final String LOOPBACK_REDIRECT = "http://127.0.0.1/callback";
+ /// Where the code is sent back to when the app runs in a browser: a path on
+ /// this server, which answers with the code it was given. See
+ /// [#withWebRedirect(RegisteredClient, String, String)].
+ public static final String WEB_REDIRECT_PATH = "/signin/code";
+ /// The key of the issuer setting: the address tokens name as their issuer.
+ /// Empty on a development profile, where the server answers on whatever
+ /// address it was reached by.
+ public static final String ISSUER_KEY = "cn1.security.authorizationserver.issuer";
+
+ private AccountSecurity() {
+ }
+
+ /// How passwords are stored: the delegating encoder, which records the
+ /// scheme with each hash so a stronger one can be adopted later.
+ public static PasswordEncoder passwordEncoder() {
+ return PasswordEncoderFactories.createDelegatingPasswordEncoder();
+ }
+
+ /// The accounts, in the framework's own user tables.
+ public static JdbcUserDetailsManager users(DataSource db) {
+ return new JdbcUserDetailsManager(db);
+ }
+
+ /// The app as the authorization server's one client, registered on first
+ /// start.
+ ///
+ /// @param id the row id of the registration
+ /// @param clientId the id the app signs in as. A public client: it holds no
+ /// secret, since anything shipped inside an app can be read out of it,
+ /// and proves itself with PKCE instead.
+ /// @param clientName what the sign-in pages call the app
+ /// @param issuer the [#ISSUER_KEY] setting
+ public static RegisteredClientRepository clients(DataSource db, final String id,
+ final String clientId, String clientName, final String issuer) throws IOException {
+ final JdbcRegisteredClientRepository clients = new JdbcRegisteredClientRepository(db);
+ if (clients.findByClientId(clientId) == null) {
+ clients.save(RegisteredClient.withId(id).clientId(clientId)
+ .clientName(clientName)
+ .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ .redirectUri(LOOPBACK_REDIRECT)
+ .scope("openid").scope("profile")
+ .build());
+ }
+ // The app in a browser is the same client with one more address: this
+ // server's own. It is worked out and not stored, because on a
+ // development profile the server has no one address to store -- and a
+ // row written by an earlier version would not have it either.
+ return new RegisteredClientRepository() {
+ @Override
+ public void save(RegisteredClient client) {
+ clients.save(client);
+ }
+
+ @Override
+ public RegisteredClient findByClientId(String wanted) {
+ return withWebRedirect(clients.findByClientId(wanted), clientId, issuer);
+ }
+
+ @Override
+ public RegisteredClient findById(String wanted) {
+ return withWebRedirect(clients.findById(wanted), clientId, issuer);
+ }
+ };
+ }
+
+ /// The app's registration with [#WEB_REDIRECT_PATH] on this server added
+ /// to the addresses a code may be sent back to.
+ ///
+ /// A browser will not show a page the answer to a request that was
+ /// redirected to another origin, so the app running in one cannot read its
+ /// code off a redirect to the loopback address the way the installed app
+ /// does. It asks for the code to be sent to this server instead, which
+ /// hands it straight back from a page the application serves there.
+ ///
+ /// "This server" is the configured issuer where there is one, and is then
+ /// the only web address accepted. With none -- a development profile -- it
+ /// is the address the request arrived at, the same rule
+ /// [#issuedAtThisAddress] holds a token to: a code is only ever sent back
+ /// to the server the browser was already talking to.
+ public static RegisteredClient withWebRedirect(RegisteredClient client, String clientId,
+ String issuer) {
+ if (client == null || !clientId.equals(client.getClientId())) {
+ return client;
+ }
+ String here = here(issuer);
+ if (here == null) {
+ return client;
+ }
+ RegisteredClient.Builder copy = RegisteredClient.withId(client.getId())
+ .clientId(client.getClientId())
+ .clientName(client.getClientName())
+ .clientSettings(client.getClientSettings())
+ .tokenSettings(client.getTokenSettings())
+ .redirectUri(here + WEB_REDIRECT_PATH);
+ for (ClientAuthenticationMethod method : client.getClientAuthenticationMethods()) {
+ copy.clientAuthenticationMethod(method);
+ }
+ for (AuthorizationGrantType grant : client.getAuthorizationGrantTypes()) {
+ copy.authorizationGrantType(grant);
+ }
+ for (String uri : client.getRedirectUris()) {
+ copy.redirectUri(uri);
+ }
+ for (String scope : client.getScopes()) {
+ copy.scope(scope);
+ }
+ for (String resource : client.getResources()) {
+ copy.resource(resource);
+ }
+ return copy.build();
+ }
+
+ /// Refuses a request that changes something and was sent by a page of
+ /// another site.
+ ///
+ /// A browser names the site a request came from in `Origin` on every
+ /// request that is not a plain read, and a page cannot change or drop it.
+ /// The installed app is not a browser and sends none, so a request without
+ /// one is let through: there is no cookie jar of somebody else's for it to
+ /// be riding on.
+ public static SecurityFilter sameOrigin(final String issuer) {
+ return (request, chain) -> {
+ String method = request.getMethod();
+ if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) {
+ return chain.doFilter(request);
+ }
+ String origin = request.getHeader("Origin");
+ if (origin == null || origin.length() == 0) {
+ return chain.doFilter(request);
+ }
+ String here = here(issuer);
+ if (here != null && sameOrigin(origin, here)) {
+ return chain.doFilter(request);
+ }
+ return new HttpServer.Response(403, "application/json",
+ "{\"error\":\"cross_origin\"}".getBytes("UTF-8"))
+ .header("Cache-Control", "no-store");
+ };
+ }
+
+ /// This server's address: the configured issuer, and with none -- a
+ /// development profile -- the address the request in hand arrived at. Null
+ /// outside a request that says.
+ ///
+ /// Configure the issuer in production. Without one, the trusted origin and
+ /// the web redirect follow the request's `Host` header, which a proxy that
+ /// forwards it unchanged lets the client choose.
+ public static String here(String issuer) {
+ if (issuer != null && issuer.length() > 0) {
+ // "https://x/" and "https://x" are one server; paths are appended.
+ return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer;
+ }
+ SecurityExchange exchange = SecurityExchange.current();
+ String host = exchange == null ? null : exchange.getRequest().getHeader("Host");
+ if (host == null || host.length() == 0) {
+ return null;
+ }
+ return (exchange.isSecure() ? "https://" : "http://") + host;
+ }
+
+ /// Whether `origin` is the origin of `address`: the same scheme, host and
+ /// port, whatever path the address goes on to name. Scheme and host are
+ /// compared without regard to case, and without folding it -- that would
+ /// depend on the language the server runs in.
+ public static boolean sameOrigin(String origin, String address) {
+ int path = address.indexOf('/', address.indexOf("://") + 3);
+ String root = path < 0 ? address : address.substring(0, path);
+ return origin.equalsIgnoreCase(root);
+ }
+
+ /// The grants signed-in devices hold, in the authorization server's table.
+ public static OAuth2AuthorizationService authorizations(DataSource db) {
+ return new JdbcOAuth2AuthorizationService(db);
+ }
+
+ /// The keys tokens are signed with, from the server's configuration.
+ public static JwkSource signingKeys(Config config) throws IOException {
+ return AuthorizationServerKeys.load(config);
+ }
+
+ /// The authorization server's chain: the sign-in form, signing out, and
+ /// the OAuth endpoints. Return it from an `@Order(1)` bean, ahead of the
+ /// API's chain.
+ ///
+ /// @param issuer the [#ISSUER_KEY] setting
+ /// @param signInsPerMinute how many sign-in attempts one address may make a
+ /// minute; ten is right for people, tests signing many in raise it
+ /// @param audience whom the access tokens are for; the API accepts no token
+ /// issued for anything else
+ public static SecurityFilterChain signIn(HttpSecurity http, final String issuer,
+ int signInsPerMinute, String audience) throws IOException {
+ http.securityMatcher("/oauth2/**", "/.well-known/**", "/userinfo", "/login", "/login/**",
+ "/logout")
+ .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ // No CSRF token: the app posts the sign-in form itself, from its own
+ // screen, and would have to fetch a token first to send one. What a
+ // token is for still has to be done, because the app also runs in a
+ // browser, where the session is a cookie the browser keeps and would
+ // send along with a form another site submitted -- signing its
+ // visitor in as somebody else. Two things stand in for it. The
+ // session cookie is `SameSite=Lax`, the server's default, so it does
+ // not travel with another site's request; and [#sameOrigin] below
+ // refuses any request that changes something and says it came from
+ // another site, which is the one a forged sign-in is. A server that
+ // signs people in through a page it serves keeps CSRF on.
+ .csrf(csrf -> csrf.disable())
+ .addFilterBefore(sameOrigin(issuer), LogoutFilter.class)
+ // Signing out is a POST and nothing else -- with CSRF off the
+ // default takes a GET too, which an image tag on any page can send
+ // -- and is answered with a status, for the reason signing in is.
+ .logout(logout -> logout
+ .logoutRequestMatcher(AntPathRequestMatcher.antMatcher("POST", "/logout"))
+ .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(204)))
+ // And it is answered with a status, not a redirect to a page:
+ // the app in a browser cannot see a redirect or the cookie, only
+ // how the request ended, so that has to be the answer. The session
+ // cookie is set either way; the installed app reads it, a browser
+ // keeps it.
+ .formLogin(form -> form
+ .successHandler((request, authentication) -> new HttpServer.Response(200,
+ "application/json", "{\"signedIn\":true}".getBytes("UTF-8"))
+ .header("Cache-Control", "no-store"))
+ .failureHandler((request, refused) -> new HttpServer.Response(401,
+ "application/json", "{\"signedIn\":false}".getBytes("UTF-8"))
+ .header("Cache-Control", "no-store")))
+ // Guessing passwords is the attack on this chain. Ten tries a minute
+ // from one address, unless the application's setting says otherwise:
+ // the end-to-end tests are many people signing in from one address,
+ // and their profile raises it.
+ .rateLimit("/login", RateLimitKeys.clientAddress(),
+ new InMemoryRateLimiter(signInsPerMinute, 60))
+ .authorizationServer(server -> server
+ .settings(AuthorizationServerSettings.builder()
+ .defaultAudience(audience).build()));
+ return http.build();
+ }
+
+ /// Reads and checks an access token this server issued for `audience`.
+ public static DefaultJwtDecoder tokenDecoder(JwkSource signingKeys, String issuer,
+ String audience) {
+ DefaultJwtDecoder decoder = DefaultJwtDecoder.withJwkSource(signingKeys).build();
+ decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
+ new JwtAudienceValidator(audience),
+ issuer != null && issuer.length() > 0 ? new JwtIssuerValidator(issuer)
+ : new JwtClaimValidator("iss", AccountSecurity::issuedAtThisAddress)));
+ return decoder;
+ }
+
+ /// Turns a token into the account's roles, read from the account on every
+ /// request rather than from the token.
+ public static JwtAuthenticationConverter accountRoles(final JdbcUserDetailsManager users) {
+ // The token says who the caller is and nothing about what they may do.
+ // The roles are read from the account on every request instead of being
+ // copied into the token, for one reason: an admin who withdraws a role
+ // or suspends an account means now, not when the token runs out. It
+ // costs a lookup by primary key per request.
+ JwtAuthenticationConverter roles = new JwtAuthenticationConverter();
+ roles.setJwtGrantedAuthoritiesConverter(jwt -> {
+ String name = jwt.getSubject();
+ if (name == null || !users.userExists(name)) {
+ throw new DisabledException("No such account");
+ }
+ UserDetails account = users.loadUserByUsername(name);
+ if (!account.isEnabled() || !account.isAccountNonLocked()) {
+ throw new DisabledException("This account is suspended");
+ }
+ Collection held = new ArrayList();
+ held.addAll(account.getAuthorities());
+ return held;
+ });
+ return roles;
+ }
+
+ /// Whether a token's issuer is the address this request came to.
+ ///
+ /// Used only when no issuer is configured, which the server allows only on
+ /// a development profile: it then issues tokens in the name of whatever
+ /// address each request arrived at, so that is the name to hold a token to.
+ public static boolean issuedAtThisAddress(Object issuer) {
+ SecurityExchange exchange = SecurityExchange.current();
+ if (exchange == null || !(issuer instanceof String)) {
+ return false;
+ }
+ String host = exchange.getRequest().getHeader("Host");
+ return host != null && ((exchange.isSecure() ? "https://" : "http://") + host)
+ .equals(issuer);
+ }
+}
diff --git a/maven/cn1-app-kit/backend/src/main/resources/META-INF/codenameone/backend-library b/maven/cn1-app-kit/backend/src/main/resources/META-INF/codenameone/backend-library
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/maven/cn1-app-kit/common/codenameone_library_appended.properties b/maven/cn1-app-kit/common/codenameone_library_appended.properties
new file mode 100644
index 00000000000..3417a0e1ba9
--- /dev/null
+++ b/maven/cn1-app-kit/common/codenameone_library_appended.properties
@@ -0,0 +1,2 @@
+# Build hints the app kit adds to an application that uses it: none. The file
+# is what marks this module as a cn1lib's common module to the cn1lib goal.
diff --git a/maven/cn1-app-kit/common/codenameone_library_required.properties b/maven/cn1-app-kit/common/codenameone_library_required.properties
new file mode 100644
index 00000000000..7d5d6259770
--- /dev/null
+++ b/maven/cn1-app-kit/common/codenameone_library_required.properties
@@ -0,0 +1 @@
+# Build hints an application using the app kit must have: none.
diff --git a/maven/cn1-app-kit/common/pom.xml b/maven/cn1-app-kit/common/pom.xml
new file mode 100644
index 00000000000..b9dde9bec05
--- /dev/null
+++ b/maven/cn1-app-kit/common/pom.xml
@@ -0,0 +1,40 @@
+
+
+ 4.0.0
+
+ com.codenameone
+ cn1-app-kit
+ 8.0-SNAPSHOT
+
+ cn1-app-kit-common
+ jar
+ Codename One App Kit: client
+
+
+ com.codenameone
+ codenameone-core
+ ${project.version}
+ provided
+
+
+
+
+
+ com.codenameone
+ codenameone-maven-plugin
+ ${project.version}
+
+
+ build-legacy-cn1lib
+ package
+
+ cn1lib
+
+
+
+
+
+
+
diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java
new file mode 100644
index 00000000000..30f7b6d60ed
--- /dev/null
+++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Chart.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit;
+
+import com.codename1.ui.CN;
+import com.codename1.ui.Component;
+import com.codename1.ui.Container;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.Label;
+import com.codename1.ui.geom.Dimension;
+import com.codename1.ui.layouts.BorderLayout;
+import com.codename1.ui.plaf.Style;
+import com.codename1.ui.plaf.UIManager;
+
+/// A row of bars: orders a day, takings a day, requests by the hour.
+///
+/// Drawn here, in a few lines, because that is all a bar chart is and because
+/// it then takes its colours from `theme.css` like everything else: the bars
+/// are the `Chart` style's colour, the highest of them `ChartPeak`'s, and
+/// the lines behind them `ChartGrid`'s, and the captions under it
+/// `ChartAxis`. The prefix is the application's own, so each app styles
+/// its charts in its own theme. Nothing here names a colour.
+public final class Chart extends Component {
+ private static final float HEIGHT_MM = 24f;
+
+ private final double[] values;
+ private final String prefix;
+
+ private Chart(String prefix, double[] values) {
+ this.values = values;
+ this.prefix = prefix;
+ setUIID(prefix + "Chart");
+ setFocusable(false);
+ }
+
+ /// The chart, with what its two ends are written under it.
+ ///
+ /// @param prefix what the chart's styles are named after: `Wl` styles it
+ /// with `WlChart`, `WlChartPeak`, `WlChartGrid` and `WlChartAxis`
+ /// @param name what a test finds the chart by
+ /// @param from the caption under the first bar, already translated
+ /// @param to the caption under the last
+ public static Container of(String prefix, String name, double[] values, String from,
+ String to) {
+ Chart chart = new Chart(prefix, values);
+ chart.setName(name);
+ Container box = new Container(new BorderLayout());
+ box.add(BorderLayout.CENTER, chart);
+ Container axis = new Container(new BorderLayout());
+ axis.add(BorderLayout.WEST, caption(from, prefix));
+ axis.add(BorderLayout.EAST, caption(to, prefix));
+ box.add(BorderLayout.SOUTH, axis);
+ return box;
+ }
+
+ /// A caption, shown as given: it arrives translated.
+ private static Label caption(String text, String prefix) {
+ Label label = new Label("", prefix + "ChartAxis");
+ label.setShouldLocalize(false);
+ label.setText(text == null ? "" : text);
+ return label;
+ }
+
+ /// How many bars there are, for a test.
+ public int bars() {
+ return values.length;
+ }
+
+ @Override
+ protected Dimension calcPreferredSize() {
+ Style style = getStyle();
+ return new Dimension(CN.convertToPixels(40f),
+ CN.convertToPixels(HEIGHT_MM) + style.getVerticalPadding());
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ Style style = getStyle();
+ int left = getX() + style.getPaddingLeftNoRTL();
+ int top = getY() + style.getPaddingTop();
+ int width = getWidth() - style.getHorizontalPadding();
+ int height = getHeight() - style.getVerticalPadding();
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ UIManager styles = getUIManager();
+ g.setColor(styles.getComponentStyle(prefix + "ChartGrid").getFgColor());
+ // Three lines to read the heights against: the foot, half, the top.
+ for (int line = 0; line <= 2; line++) {
+ int y = top + (height - 1) * line / 2;
+ g.drawLine(left, y, left + width, y);
+ }
+ int count = values.length;
+ if (count == 0) {
+ return;
+ }
+ double most = 0;
+ int peak = -1;
+ for (int iter = 0; iter < count; iter++) {
+ if (values[iter] > most) {
+ most = values[iter];
+ peak = iter;
+ }
+ }
+ if (most <= 0) {
+ return;
+ }
+ // The space is shared out in whole pixels, so every bar is as wide as
+ // every other and what is left over goes to the two ends.
+ int slot = Math.max(1, width / count);
+ int gap = Math.max(1, slot / 4);
+ int bar = Math.max(1, slot - gap);
+ int start = left + (width - slot * count + gap) / 2;
+ int round = Math.min(bar, CN.convertToPixels(1.2f));
+ int normal = style.getFgColor();
+ int highest = styles.getComponentStyle(prefix + "ChartPeak").getFgColor();
+ boolean rtl = isRTL();
+ g.setAntiAliased(true);
+ for (int iter = 0; iter < count; iter++) {
+ int tall = (int) Math.round(values[iter] / most * (height - 2));
+ if (values[iter] > 0 && tall < round) {
+ tall = round;
+ }
+ if (tall <= 0) {
+ continue;
+ }
+ // Time runs the way the language is read.
+ int at = rtl ? count - 1 - iter : iter;
+ g.setColor(iter == peak ? highest : normal);
+ g.fillRoundRect(start + at * slot, top + height - tall, bar, tall, round, round);
+ }
+ }
+
+ /// A share of something, as a bar part of the way along a track: the bar
+ /// is the `Meter` style's colour and the track a faint wash of it.
+ public static final class Meter extends Component {
+ private static final int TRACK_ALPHA = 36;
+ private final double share;
+
+ /// @param prefix what the style is named after: `Wl` reads `WlMeter`
+ /// @param share from 0 to 1
+ public Meter(String prefix, double share) {
+ this.share = share < 0 ? 0 : share > 1 ? 1 : share;
+ setUIID(prefix + "Meter");
+ setFocusable(false);
+ }
+
+ @Override
+ protected Dimension calcPreferredSize() {
+ Style style = getStyle();
+ return new Dimension(CN.convertToPixels(20f),
+ CN.convertToPixels(1.6f) + style.getVerticalPadding());
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ Style style = getStyle();
+ int left = getX() + style.getPaddingLeftNoRTL();
+ int top = getY() + style.getPaddingTop();
+ int width = getWidth() - style.getHorizontalPadding();
+ int height = getHeight() - style.getVerticalPadding();
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ g.setAntiAliased(true);
+ // The track is the bar's own colour, faint: a background colour
+ // would be painted square behind the rounded ends.
+ int alpha = g.getAlpha();
+ g.setColor(style.getFgColor());
+ g.setAlpha(TRACK_ALPHA);
+ g.fillRoundRect(left, top, width, height, height, height);
+ g.setAlpha(alpha);
+ int filled = (int) Math.round(width * share);
+ if (filled > 0) {
+ filled = Math.max(filled, height);
+ g.setColor(style.getFgColor());
+ g.fillRoundRect(isRTL() ? left + width - filled : left, top, filled, height,
+ height, height);
+ }
+ }
+ }
+}
diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java
new file mode 100644
index 00000000000..6a122e773b2
--- /dev/null
+++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Net.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit;
+
+import com.codename1.io.rest.Response;
+import com.codename1.util.OnComplete;
+import com.codename1.ui.plaf.UIManager;
+
+/// Makes a call through a generated client and sorts out what came back.
+///
+/// A generated method answers with a `Response` whatever happened: null when
+/// the server could not be reached, and otherwise a status with either the
+/// typed result or, for a refusal, the server's reason as text. This turns that
+/// into one of two callbacks, so a screen writes what to do with the result and
+/// what to do with a failure, and nothing else.
+///
+/// ```java
+/// Api.orders().order(id, Net.to(order -> show(order), Ui::fail));
+/// ```
+///
+/// Every message it makes up is passed through the theme's localization
+/// bundle with the English text as its key, so an app translates them in its
+/// bundles like any other text. Two hooks fit it to an app that needs more:
+/// [#setEpoch(Epoch)] drops answers that belong to a session no longer on
+/// screen, and [#setReasons(Reasons)] turns a server's refusal into the words
+/// shown.
+public final class Net {
+ /// A call to a generated client method, waiting for its callback.
+ public interface Call {
+ void run(OnComplete> callback);
+ }
+
+ public interface Ok {
+ void got(T value);
+ }
+
+ public interface Failed {
+ /// @param status the HTTP status, or 0 when the server was not reached
+ /// @param message something to show the user
+ void failed(int status, String message);
+ }
+
+ /// The session an answer belongs to. A call remembers the value when it
+ /// is made, and its answer is dropped, neither callback running, when the
+ /// value has moved on by the time it arrives: the app locked, or the user
+ /// signed out or in again while it was out.
+ public interface Epoch {
+ int current();
+ }
+
+ /// What a failure says to the user. Asked about every failure, so an app
+ /// that keeps state about the last one (a refusal code it reads in its
+ /// [Failed] callback) can clear it when the next failure has none.
+ public interface Reasons {
+ /// @param status the HTTP status, or 0 when the server was not reached
+ /// @param body the text the server refused with, when it answered with
+ /// a short line of text; otherwise null
+ /// @return what to show, or null for what this class would show
+ String reason(int status, String body);
+ }
+
+ private static Epoch epoch;
+ private static Reasons reasons;
+
+ private Net() {
+ }
+
+ /// Sets the session counter answers are checked against; null, the
+ /// default, delivers every answer.
+ public static void setEpoch(Epoch epoch) {
+ Net.epoch = epoch;
+ }
+
+ /// Sets what a refusal is shown as; null, the default, shows the server's
+ /// text.
+ public static void setReasons(Reasons reasons) {
+ Net.reasons = reasons;
+ }
+
+ private static int epochNow() {
+ Epoch e = epoch;
+ return e == null ? 0 : e.current();
+ }
+
+ private static String custom(int status, String body) {
+ Reasons r = reasons;
+ return r == null ? null : r.reason(status, body);
+ }
+
+ private static String tr(String text) {
+ return UIManager.getInstance().localize(text, text);
+ }
+
+ /// The callback to hand a generated method: `ok` gets the result, `failed`
+ /// everything else.
+ public static OnComplete> to(final Ok ok, final Failed failed) {
+ final int asked = epochNow();
+ return new OnComplete>() {
+ @Override
+ public void completed(Response response) {
+ if (asked != epochNow()) {
+ return;
+ }
+ if (response == null) {
+ String shown = custom(0, null);
+ failed.failed(0, shown != null ? shown
+ : tr("Could not reach the server. Check your connection."));
+ return;
+ }
+ int status = response.getResponseCode();
+ if (status >= 200 && status < 300) {
+ ok.got(response.getResponseData());
+ return;
+ }
+ failed.failed(status, reason(response, status));
+ }
+ };
+ }
+
+ /// Makes a call that was handed over as a value, which is how one screen
+ /// serves several calls of the same shape.
+ public static void send(Call call, Ok ok, Failed failed) {
+ call.run(to(ok, failed));
+ }
+
+ /// What the server said when it refused. Read as an `Object` and tested,
+ /// never cast to the type the call was declared with: on a refusal the body
+ /// is text whatever that type is.
+ private static String reason(Response> response, int status) {
+ Object data = response.getResponseData();
+ String body = null;
+ if (data instanceof String && ((String) data).length() > 0
+ && ((String) data).length() < 300 && ((String) data).indexOf('<') < 0
+ && ((String) data).indexOf('{') < 0) {
+ body = (String) data;
+ }
+ String shown = custom(status, body);
+ if (shown != null) {
+ return shown;
+ }
+ if (body != null) {
+ return body;
+ }
+ if (status == 401) {
+ return tr("Please sign in again.");
+ }
+ if (status == 403) {
+ return tr("Your account is not allowed to do that.");
+ }
+ if (status == 429) {
+ return tr("Too many attempts. Wait a moment and try again.");
+ }
+ String text = tr("Something went wrong ({0}). Try again.");
+ int at = text.indexOf("{0}");
+ return at < 0 ? text : text.substring(0, at) + status + text.substring(at + 3);
+ }
+}
diff --git a/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java
new file mode 100644
index 00000000000..c18b8309cd9
--- /dev/null
+++ b/maven/cn1-app-kit/common/src/main/java/com/codename1/appkit/Skeleton.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.appkit;
+
+import com.codename1.ui.CN;
+import com.codename1.ui.Component;
+import com.codename1.ui.Graphics;
+import com.codename1.ui.geom.Dimension;
+import com.codename1.ui.plaf.Style;
+
+/// What a list shows while what is in it is on its way: the outline of a few
+/// rows, drawn as bars where the icon, the two lines and the value will be.
+///
+/// It says "this is loading" without a word and without moving, and it keeps
+/// the screen from jumping when the rows arrive, because they land where the
+/// outline was. The colour is the foreground of the style it is given.
+public final class Skeleton extends Component {
+ /// How tall one row is drawn, in millimetres: a row of two lines.
+ private static final float ROW_MM = 9.6f;
+
+ private final int rows;
+
+ /// @param rows how many rows to outline
+ /// @param uiid the style whose foreground the bars are drawn in
+ public Skeleton(int rows, String uiid) {
+ this.rows = rows;
+ setUIID(uiid);
+ setFocusable(false);
+ }
+
+ @Override
+ protected Dimension calcPreferredSize() {
+ Style style = getStyle();
+ return new Dimension(CN.convertToPixels(40f) + style.getHorizontalPadding(),
+ rows * CN.convertToPixels(ROW_MM) + style.getVerticalPadding());
+ }
+
+ @Override
+ public void paint(Graphics g) {
+ Style style = getStyle();
+ int left = getX() + style.getPaddingLeftNoRTL();
+ int width = getWidth() - style.getHorizontalPadding();
+ int row = CN.convertToPixels(ROW_MM);
+ int dot = CN.convertToPixels(4.4f);
+ int tall = CN.convertToPixels(1.9f);
+ int thin = CN.convertToPixels(1.5f);
+ int gap = CN.convertToPixels(2.6f);
+ if (width <= dot + gap) {
+ return;
+ }
+ boolean rtl = isRTL();
+ g.setAntiAliased(true);
+ g.setColor(style.getFgColor());
+ int lines = width - dot - gap;
+ for (int iter = 0; iter < rows; iter++) {
+ int top = getY() + style.getPaddingTop() + iter * row;
+ int middle = top + row / 2;
+ // Each row a little shorter than the one before: a list of
+ // identical bars reads as a pattern, not as a list.
+ int first = lines * (48 - iter % 3 * 7) / 100;
+ int second = lines * (30 + iter % 2 * 9) / 100;
+ int value = lines * 14 / 100;
+ bar(g, rtl, left, width, 0, middle - dot / 2, dot, dot);
+ bar(g, rtl, left, width, dot + gap, middle - tall - thin / 3, first, tall);
+ bar(g, rtl, left, width, dot + gap, middle + thin * 2 / 3, second, thin);
+ bar(g, rtl, left, width, width - value, middle - tall / 2, value, tall);
+ }
+ }
+
+ /// One bar, `from` pixels in from the side a line of text starts at.
+ private static void bar(Graphics g, boolean rtl, int left, int width, int from, int y,
+ int w, int h) {
+ int x = rtl ? left + width - from - w : left + from;
+ g.fillRoundRect(x, y, w, h, h, h);
+ }
+}
diff --git a/maven/cn1-app-kit/lib/pom.xml b/maven/cn1-app-kit/lib/pom.xml
new file mode 100644
index 00000000000..cbcfdeac297
--- /dev/null
+++ b/maven/cn1-app-kit/lib/pom.xml
@@ -0,0 +1,27 @@
+
+
+ 4.0.0
+
+ com.codenameone
+ cn1-app-kit
+ 8.0-SNAPSHOT
+
+ cn1-app-kit-lib
+ pom
+ Codename One App Kit: library
+
+
+
+
+ com.codenameone
+ cn1-app-kit-common
+ ${project.version}
+
+
+
diff --git a/maven/cn1-app-kit/pom.xml b/maven/cn1-app-kit/pom.xml
new file mode 100644
index 00000000000..d730bbd4197
--- /dev/null
+++ b/maven/cn1-app-kit/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+ com.codenameone
+ codenameone
+ 8.0-SNAPSHOT
+
+ cn1-app-kit
+ pom
+ Codename One App Kit
+ The account, networking and notification plumbing the full-stack samples share, client and server
+
+
+ cn1-app-kit
+ 1.8
+ 1.8
+ 1.8
+ UTF-8
+
+
+
+
+
+ com.github.spotbugs
+ spotbugs-maven-plugin
+ 4.7.3.4
+
+ Max
+ Low
+ true
+ true
+ ${project.build.directory}
+ ${maven.multiModuleProjectDirectory}/cn1-app-kit/spotbugs-exclude.xml
+
+
+
+ spotbugs
+ verify
+
+ spotbugs
+ check
+
+
+
+
+
+
+
+ backend
+ common
+ lib
+
+
diff --git a/maven/cn1-app-kit/spotbugs-exclude.xml b/maven/cn1-app-kit/spotbugs-exclude.xml
new file mode 100644
index 00000000000..7552968b166
--- /dev/null
+++ b/maven/cn1-app-kit/spotbugs-exclude.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/maven/pom.xml b/maven/pom.xml
index 7c3bea5cf4c..b5c0b8f9967 100644
--- a/maven/pom.xml
+++ b/maven/pom.xml
@@ -150,6 +150,9 @@
cn1-applovin
cn1-unity-levelplay
cn1-ads-mock
+
+ cn1-app-kit