From f1f2e71f2f02fd0d2eadabaca4fe64a0ce404c30 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Wed, 2 Sep 2026 07:41:50 +0300
Subject: [PATCH 01/25] State restoration and continuity across devices
A Codename One app that the operating system kills comes back to its first
screen. Lifecycle.stop() kept the current Form in a plain field, so a suspend
and resume looked right and a reclaimed process lost everything -- which on
Android is the ordinary outcome of a few minutes in another app.
com.codename1.continuity saves what the user was doing and brings it back, and
on Apple platforms offers that same work to the other devices the person is
signed in to. The substrate was already here and unused: com.codename1.router
keeps a stack of deep-link paths, which is exactly a serializable, portable
"where the user is", and Navigation.restoreStack rebuilds it without animating
through every screen on the way.
Two packages, because they cost different things. com.codename1.continuity
buys a native define and one NSUserActivityTypes entry and no entitlement;
com.codename1.continuity.sync buys the iCloud key-value store, whose
entitlement has to be granted on the App ID. Handing that to an app that only
wanted to pass work to the tablet in the user's other hand would fail its
codesigning for a capability it never asked for -- the same split, for the same
reason, as usesSmartHome and usesHomeAccessoryData.
Three decisions worth recording:
- Saving is continuous, not at shutdown. Every navigation schedules a
checkpoint that is written once per event loop pass. Android's generated
activity blocks the platform main thread until the app's stop() returns, so
an app that saved there would pay for it on every suspend.
- Nothing happens until the application opts in. start() is unchanged for every
existing app, and restore() is never called for anyone -- where restoration
belongs in a launch is a decision only the app can make.
- Codename One runs no relay server. Continuation between Apple devices is the
platform's; everything else goes through a StateRelay against the app's own
endpoint, because deciding which saved states belong to the same person is
the app's account system's question.
The iOS delegate matches continuity BEFORE intents, and the order is
load-bearing: the intents block ends in a general branch that hands any
remaining activity to Java and returns Java's answer, and
Intents.dispatchUserActivity correctly declines a type it never declared. An
app using both would have had its own continuation asked about by the wrong
framework, told no, and dropped. NSUserActivityTypes stays a single key for the
same reason a second one is worse than none: iOS reads a duplicated key
unpredictably, so the two contributors meet in userActivityTypesKey.
Android needs nothing injected -- no permission, no manifest entry, no
dependency -- and the bridge exists there for one job: flushing the checkpoint
from onSaveInstanceState, the last callback guaranteed before a background
process is reclaimed. Both cross-device capabilities report themselves
unsupported rather than being emulated, because an app told "yes" by a bridge
that then dropped the state is worse off than one told "no", which can fall
back to a relay and reach an iPhone as easily as another Android.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../com/codename1/continuity/AppState.java | 333 ++++++
.../com/codename1/continuity/Continuity.java | 961 ++++++++++++++++++
.../continuity/ContinuityListener.java | 52 +
.../codename1/continuity/RestStateRelay.java | 144 +++
.../com/codename1/continuity/StateCodec.java | 272 +++++
.../codename1/continuity/StateProvider.java | 60 ++
.../com/codename1/continuity/StateRelay.java | 63 ++
.../codename1/continuity/package-info.java | 37 +
.../continuity/spi/ContinuityBridge.java | 101 ++
.../continuity/spi/ContinuityCallback.java | 52 +
.../continuity/spi/package-info.java | 27 +
.../continuity/sync/SyncedStore.java | 259 +++++
.../continuity/sync/SyncedStoreListener.java | 33 +
.../continuity/sync/package-info.java | 30 +
.../impl/CodenameOneImplementation.java | 14 +
.../continuity/LocalContinuityBridge.java | 233 +++++
.../impl/continuity/package-info.java | 26 +
.../src/com/codename1/router/Navigation.java | 71 ++
CodenameOne/src/com/codename1/ui/Display.java | 12 +
.../impl/android/AndroidImplementation.java | 20 +
.../continuity/AndroidContinuityBridge.java | 156 +++
.../codenameone/simulator-hooks.properties | 50 +-
.../impl/javase/ContinuitySimulatorHooks.java | 191 ++++
.../com/codename1/impl/javase/JavaSEPort.java | 51 +
.../nativeSources/CodenameOne_GLAppDelegate.m | 68 +-
.../CodenameOne_GLViewController.h | 14 +
Ports/iOSPort/nativeSources/IOSNative.m | 297 ++++++
.../impl/ios/IOSContinuityBridge.java | 181 ++++
.../impl/ios/IOSContinuityCallbacks.java | 145 +++
.../codename1/impl/ios/IOSImplementation.java | 15 +
.../src/com/codename1/impl/ios/IOSNative.java | 39 +
.../ContinuitySample/ContinuitySample.java | 228 +++++
.../codenameone_settings.properties | 9 +
.../continuity/ContinuitySnippets.java | 175 ++++
...tate-restoration-and-continuity.properties | 9 +
.../State-Restoration-And-Continuity.asciidoc | 306 ++++++
docs/developer-guide/developer-guide.asciidoc | 2 +
docs/website/data/port_status.json | 9 +
.../codename1/build/shared/BuildHintsIos.java | 24 +
.../com/codename1/builders/IPhoneBuilder.java | 121 ++-
.../com/codename1/maven/CN1BuildMojo.java | 1 +
.../maven/IOSProvisioningPreflight.java | 82 ++
.../IPhoneBuilderContinuityPlistTest.java | 195 ++++
.../maven/IOSContinuitySyncPreflightTest.java | 177 ++++
.../continuity/AppStateWireTest.java | 266 +++++
.../continuity/ContinuityDegradationTest.java | 202 ++++
.../continuity/LocalContinuityTest.java | 504 +++++++++
.../continuity/RouteStackRestoreTest.java | 236 +++++
.../simulator/ShippedSimulatorHooksTest.java | 179 ++++
.../tests/Cn1ssDeviceRunner.java | 6 +
.../tests/ContinuityStateTest.java | 189 ++++
.../resources/skill/references/build-hints.md | 25 +
52 files changed, 6941 insertions(+), 11 deletions(-)
create mode 100644 CodenameOne/src/com/codename1/continuity/AppState.java
create mode 100644 CodenameOne/src/com/codename1/continuity/Continuity.java
create mode 100644 CodenameOne/src/com/codename1/continuity/ContinuityListener.java
create mode 100644 CodenameOne/src/com/codename1/continuity/RestStateRelay.java
create mode 100644 CodenameOne/src/com/codename1/continuity/StateCodec.java
create mode 100644 CodenameOne/src/com/codename1/continuity/StateProvider.java
create mode 100644 CodenameOne/src/com/codename1/continuity/StateRelay.java
create mode 100644 CodenameOne/src/com/codename1/continuity/package-info.java
create mode 100644 CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java
create mode 100644 CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java
create mode 100644 CodenameOne/src/com/codename1/continuity/spi/package-info.java
create mode 100644 CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java
create mode 100644 CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java
create mode 100644 CodenameOne/src/com/codename1/continuity/sync/package-info.java
create mode 100644 CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java
create mode 100644 CodenameOne/src/com/codename1/impl/continuity/package-info.java
create mode 100644 Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java
create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java
create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java
create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java
create mode 100644 Samples/samples/ContinuitySample/ContinuitySample.java
create mode 100644 Samples/samples/ContinuitySample/codenameone_settings.properties
create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java
create mode 100644 docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties
create mode 100644 docs/developer-guide/State-Restoration-And-Continuity.asciidoc
create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java
create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/ContinuityDegradationTest.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java
create mode 100644 maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java
create mode 100644 maven/javase/src/test/java/com/codename1/impl/javase/simulator/ShippedSimulatorHooksTest.java
create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java
diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java
new file mode 100644
index 00000000000..a9d280aff8b
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/AppState.java
@@ -0,0 +1,333 @@
+/*
+ * 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.continuity;
+
+import com.codename1.io.Externalizable;
+import com.codename1.io.Util;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/// A snapshot of where the user was and what they were doing: the route stack, plus whatever your
+/// `StateProvider` chose to add.
+///
+/// The same value serves three purposes, which is why it carries more than the two halves above.
+/// It is written to storage so the app can come back after its process dies; it is advertised to
+/// the user's other devices so one of them can continue the work; and it travels through a
+/// `StateRelay` to devices the platform cannot reach on its own. The `deviceId`, `sequence` and
+/// `timestamp` are what let the receiving side tell a state it has already seen -- or its own echo
+/// -- from one worth acting on.
+///
+/// #### The routes
+///
+/// `getRoutes()` is the `com.codename1.router.Navigation` stack as a list of paths, oldest first.
+/// Restoring it re-runs each path through the route table, which is why an app that navigates with
+/// `@Route` gets its screens back for free and one that calls `new MyForm().show()` does not: those
+/// navigations are not URL-addressable, so there is nothing to write down. Such an app restores
+/// from the payload instead.
+///
+/// #### The payload
+///
+/// `getPayload()` is yours. It has to survive being written to disk, handed to an operating system
+/// and delivered to a *different device running a possibly different build of your app*, so it is
+/// restricted to values that mean the same thing everywhere: `String`, `Integer`, `Long`, `Double`,
+/// `Boolean`, and `List` and `Map` of those. Anything else is refused when the state is built,
+/// with a message naming the offending key, rather than being dropped somewhere the failure cannot
+/// be traced back here.
+public final class AppState implements Externalizable {
+ /// The `Util.register` id. Changing it orphans every state already on a device.
+ static final String OBJECT_ID = "CN1AppState";
+
+ private List routes = new ArrayList();
+ private Map payload = new HashMap();
+ private String deviceId = "";
+ private String title;
+ private long sequence;
+ private long timestamp;
+
+ /// Creates an empty state. Applications normally obtain one from
+ /// `Continuity.getRestorableState()` or receive one through a `ContinuityListener`; this is
+ /// public so tests and relays can build one.
+ public AppState() {
+ }
+
+ /// The navigation stack as route paths, oldest first. Never null, possibly empty.
+ ///
+ /// #### Returns
+ ///
+ /// an unmodifiable view of the route paths
+ public List getRoutes() {
+ return Collections.unmodifiableList(routes);
+ }
+
+ /// Replaces the route paths.
+ ///
+ /// #### Parameters
+ ///
+ /// - `r`: the paths, oldest first; null is treated as empty
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ public AppState setRoutes(List r) {
+ routes = new ArrayList();
+ if (r != null) {
+ for (Iterator i = r.iterator(); i.hasNext();) {
+ String path = i.next();
+ if (path != null && path.length() > 0) {
+ routes.add(path);
+ }
+ }
+ }
+ return this;
+ }
+
+ /// The application payload. Never null, possibly empty.
+ ///
+ /// #### Returns
+ ///
+ /// an unmodifiable view of the payload
+ public Map getPayload() {
+ return Collections.unmodifiableMap(payload);
+ }
+
+ /// Replaces the application payload.
+ ///
+ /// #### Parameters
+ ///
+ /// - `p`: the payload; null is treated as empty
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: when a value cannot cross to another device
+ public AppState setPayload(Map p) {
+ StateCodec.requireRepresentable(p);
+ payload = new HashMap();
+ if (p != null) {
+ payload.putAll(p);
+ }
+ return this;
+ }
+
+ /// Replaces the payload without validating it. Used only for a payload that arrived from
+ /// another device: it was already validated where it was produced, and refusing it here would
+ /// turn a remote mistake into an exception on this device at a moment the user cannot connect
+ /// to anything they did.
+ ///
+ /// #### Parameters
+ ///
+ /// - `p`: the payload; null is treated as empty
+ void setPayloadUnchecked(Map p) {
+ payload = new HashMap();
+ if (p != null) {
+ payload.putAll(p);
+ }
+ }
+
+ /// The device this state was produced on. Used to drop a state's own echo when it comes back
+ /// through a relay. Never null.
+ ///
+ /// #### Returns
+ ///
+ /// the originating device id
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ /// Sets the originating device id.
+ ///
+ /// #### Parameters
+ ///
+ /// - `id`: the id; null is treated as the empty string
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ public AppState setDeviceId(String id) {
+ deviceId = id == null ? "" : id;
+ return this;
+ }
+
+ /// A human readable label for what the user is doing, which a receiving device may show
+ /// before they accept the continuation. Null when the app did not set one.
+ ///
+ /// #### Returns
+ ///
+ /// the title, or null
+ public String getTitle() {
+ return title;
+ }
+
+ /// Sets the human readable label.
+ ///
+ /// #### Parameters
+ ///
+ /// - `t`: the title, or null for none
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ public AppState setTitle(String t) {
+ title = t;
+ return this;
+ }
+
+ /// A counter that increases with every state this device publishes. Together with the device
+ /// id it identifies a state exactly, which is how a receiver recognizes one it has already
+ /// acted on -- two states can share a timestamp, because clocks are coarse.
+ ///
+ /// #### Returns
+ ///
+ /// the sequence number
+ public long getSequence() {
+ return sequence;
+ }
+
+ /// Sets the sequence number.
+ ///
+ /// #### Parameters
+ ///
+ /// - `s`: the sequence number
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ public AppState setSequence(long s) {
+ sequence = s;
+ return this;
+ }
+
+ /// When this state was produced, as milliseconds since the epoch on the producing device.
+ ///
+ /// Treat it as advisory. It comes from another device's clock, so it is only as trustworthy as
+ /// that clock: it can be behind, ahead, or -- across a daylight saving change or a manual
+ /// correction -- both within one session.
+ ///
+ /// #### Returns
+ ///
+ /// the timestamp
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ /// Sets the production timestamp.
+ ///
+ /// #### Parameters
+ ///
+ /// - `t`: milliseconds since the epoch
+ ///
+ /// #### Returns
+ ///
+ /// this state, for chaining
+ public AppState setTimestamp(long t) {
+ timestamp = t;
+ return this;
+ }
+
+ /// True when there is nothing here worth restoring or sending.
+ ///
+ /// #### Returns
+ ///
+ /// true when both the routes and the payload are empty
+ public boolean isEmpty() {
+ return routes.isEmpty() && payload.isEmpty();
+ }
+
+ @Override
+ public String toString() {
+ return "AppState{routes=" + routes.size() + ", payload=" + payload.size()
+ + ", device=" + deviceId + ", seq=" + sequence + "}";
+ }
+
+ // ------------------------------------------------------------------
+ // Externalizable -- the on-device format
+ // ------------------------------------------------------------------
+
+ @Override
+ public int getVersion() {
+ return 1;
+ }
+
+ @Override
+ public String getObjectId() {
+ return OBJECT_ID;
+ }
+
+ @Override
+ public void externalize(DataOutputStream out) throws IOException {
+ Util.writeUTF(deviceId, out);
+ Util.writeUTF(title, out);
+ out.writeLong(sequence);
+ out.writeLong(timestamp);
+ out.writeInt(routes.size());
+ for (Iterator i = routes.iterator(); i.hasNext();) {
+ Util.writeUTF(i.next(), out);
+ }
+ // The payload goes through the framework's own object writer rather than a hand-rolled
+ // encoding: it already knows every type requireRepresentable admits, including nested
+ // lists and maps, and it is the same writer Storage uses for everything else.
+ Util.writeObject(payload, out);
+ }
+
+ @Override
+ public void internalize(int version, DataInputStream in) throws IOException {
+ deviceId = Util.readUTF(in);
+ if (deviceId == null) {
+ deviceId = "";
+ }
+ title = Util.readUTF(in);
+ sequence = in.readLong();
+ timestamp = in.readLong();
+ int count = in.readInt();
+ routes = new ArrayList();
+ for (int i = 0; i < count; i++) {
+ String path = Util.readUTF(in);
+ if (path != null && path.length() > 0) {
+ routes.add(path);
+ }
+ }
+ Object p = Util.readObject(in);
+ payload = new HashMap();
+ if (p instanceof Map) {
+ Map, ?> read = (Map, ?>) p;
+ for (Iterator extends Map.Entry, ?>> i = read.entrySet().iterator();
+ i.hasNext();) {
+ Map.Entry, ?> entry = i.next();
+ if (entry.getKey() instanceof String) {
+ payload.put((String) entry.getKey(), entry.getValue());
+ }
+ }
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/Continuity.java b/CodenameOne/src/com/codename1/continuity/Continuity.java
new file mode 100644
index 00000000000..642afa40107
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/Continuity.java
@@ -0,0 +1,961 @@
+/*
+ * 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.continuity;
+
+import com.codename1.continuity.spi.ContinuityBridge;
+import com.codename1.continuity.spi.ContinuityCallback;
+import com.codename1.io.Log;
+import com.codename1.io.Preferences;
+import com.codename1.io.Storage;
+import com.codename1.io.Util;
+import com.codename1.router.Navigation;
+import com.codename1.ui.Display;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/// Saves what the user was doing, brings it back when the app starts again, and -- where the
+/// platform or your own endpoint can carry it -- lets them pick it up on another device.
+///
+/// ```java
+/// // in init()
+/// Continuity.setStateProvider(new StateProvider() {
+/// public Map saveState() {
+/// Map m = new HashMap();
+/// m.put("draft", draftField.getText());
+/// return m;
+/// }
+/// public void restoreState(Map payload) {
+/// pendingDraft = (String) payload.get("draft");
+/// }
+/// });
+///
+/// // in start()
+/// if (!Continuity.restore()) {
+/// Navigation.navigate("/home");
+/// }
+/// ```
+///
+/// #### What is saved
+///
+/// Two halves. The framework contributes the `com.codename1.router.Navigation` stack, so an app
+/// whose screens are declared with `@Route` gets them back with no code at all. Your
+/// `StateProvider` contributes everything else. An app that navigates with `new MyForm().show()`
+/// has no route stack to save -- those navigations are not addressable -- and restores from the
+/// payload alone.
+///
+/// #### When it is saved
+///
+/// Continuously, not at shutdown. Every navigation marks the state dirty and a checkpoint is
+/// written once per event loop pass, so by the time the operating system suspends the app the work
+/// is already done. This is deliberate: on Android the platform blocks its own main thread until
+/// the app's `stop()` returns, and an app that did its saving there would be paying for it on
+/// every single suspend. Call `checkpoint()` directly after changing something the provider
+/// reports but no navigation touched.
+///
+/// #### Getting it back
+///
+/// `restore()` returns true when it showed something, so `start()` reads as "restore, or else
+/// begin". It is never called for you: an app that adopts this API decides where restoration fits
+/// in its own launch, and an app that does not is completely unaffected.
+///
+/// #### Other devices
+///
+/// `isContinuationSupported()` reports whether this platform can advertise the current state to
+/// the user's nearby devices; on Apple platforms it can, elsewhere it cannot and the call is a
+/// no-op rather than an error. For everything the platform will not carry -- iOS to Android, two
+/// devices that are never together -- set a `StateRelay`, which is your own endpoint. Codename One
+/// runs no server for this, because deciding which states belong to the same person is your
+/// account system's job.
+///
+/// Arriving states are offered to every `ContinuityListener` before anything happens, and this
+/// device's own echo is never offered at all.
+///
+/// #### Zero cost when unused
+///
+/// Referencing this package is what makes the build declare the activity type on Apple platforms
+/// and compile the native continuation handling in. An app that never touches
+/// `com.codename1.continuity` gets none of it.
+public final class Continuity {
+ /// The `Storage` entry the checkpoint is written to.
+ static final String STORAGE_KEY = "CN1$Continuity";
+
+ /// Where this installation's device id lives, so a state can recognize its own echo across
+ /// restarts.
+ static final String PREF_DEVICE_ID = "CN1$ContinuityDevice";
+
+ /// Where the sequence counter lives. Persisted because a counter that restarted at zero would
+ /// make every state after a relaunch look older than one the receiver had already seen.
+ static final String PREF_SEQUENCE = "CN1$ContinuitySeq";
+
+ /// How long to wait for the application to produce its first form before giving up on a
+ /// continuation that cold-launched it. A launch that never produces one is a broken
+ /// application, and restoring minutes later into whatever the user is doing by then is worse
+ /// than not restoring at all.
+ private static final long WINDOW_WAIT_MILLIS = 15000L;
+
+ private static final List listeners = new ArrayList();
+
+ /// Highest sequence seen from each device, so a state delivered twice -- which happens
+ /// routinely, since a continuation and a relay can carry the same one -- acts once.
+ private static final Map lastSeen = new HashMap();
+
+ // The fields below `bridgeOverridden` are volatile because a port delivers a continuation on
+ // whatever thread the platform hands it over on -- on Apple platforms that is the main thread,
+ // not the event dispatch thread -- while the application configures them from its own. The
+ // ones that stay plain (`dirty`, `flushScheduled`, `sequence`) are touched only from the EDT,
+ // by routeStackChanged and by the checkpoint it schedules.
+ private static volatile StateProvider provider;
+ private static volatile StateRelay relay;
+ private static volatile ContinuityBridge bridge;
+ private static volatile boolean bridgeOverridden;
+ private static volatile boolean enabled;
+ private static volatile boolean autoRestore = true;
+ private static boolean dirty;
+ private static boolean flushScheduled;
+ private static volatile boolean waitingForWindow;
+ private static volatile String deviceId;
+ private static volatile String title;
+ private static long sequence;
+ private static volatile long maxAge;
+ private static volatile AppState parked;
+
+ private Continuity() {
+ }
+
+ // ------------------------------------------------------------------
+ // Enabling
+ // ------------------------------------------------------------------
+
+ /// Turns the framework on. Called for you by `setStateProvider(StateProvider)`; call it
+ /// directly when the route stack alone is all you need saved.
+ ///
+ /// Nothing before this call has any effect, which is what keeps an app that does not use this
+ /// API behaving exactly as it always did.
+ public static void enable() {
+ if (enabled) {
+ return;
+ }
+ enabled = true;
+ // Registered once, and only from here, so that a build which merely links this class --
+ // because something else in the framework mentions it -- never installs a callback or
+ // touches storage.
+ Util.register(AppState.OBJECT_ID, AppState.class);
+ deviceId = loadDeviceId();
+ sequence = loadSequence();
+ ContinuityBridge b = bridgeInternal();
+ if (b != null) {
+ try {
+ b.setCallback(new Callback());
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ }
+
+ /// Turns the framework off. Checkpoints stop, the advertised activity is withdrawn, and
+ /// arriving states are ignored. What is already in storage is left alone -- use `clear()` to
+ /// remove it.
+ public static void disable() {
+ if (!enabled) {
+ return;
+ }
+ enabled = false;
+ dirty = false;
+ clearContinuation();
+ }
+
+ /// Whether the framework is on.
+ ///
+ /// #### Returns
+ ///
+ /// true when enabled
+ public static boolean isEnabled() {
+ return enabled;
+ }
+
+ /// Whether this platform can save and restore state at all. False only where there is no
+ /// storage to write to, which in practice means before `Display` has been initialized.
+ ///
+ /// #### Returns
+ ///
+ /// true when state can be saved on this device
+ public static boolean isSupported() {
+ return Display.isInitialized();
+ }
+
+ /// Whether this platform can advertise the current state to the user's other devices while
+ /// they are together.
+ ///
+ /// Branch on this rather than on the platform name: it is true on Apple platforms today and
+ /// the set is expected to grow, and a `com.codename1.ui.Display#getPlatformName` test would
+ /// have to be found and changed when it does.
+ ///
+ /// #### Returns
+ ///
+ /// true when continuation to a nearby device is supported
+ public static boolean isContinuationSupported() {
+ ContinuityBridge b = bridgeInternal();
+ try {
+ return b != null && b.isContinuationSupported();
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Configuration
+ // ------------------------------------------------------------------
+
+ /// Installs the object that supplies and consumes the application half of the state, and
+ /// enables the framework.
+ ///
+ /// #### Parameters
+ ///
+ /// - `p`: the provider, or null to contribute nothing beyond the route stack
+ public static void setStateProvider(StateProvider p) {
+ provider = p;
+ enable();
+ }
+
+ /// The installed state provider, or null.
+ ///
+ /// #### Returns
+ ///
+ /// the provider
+ public static StateProvider getStateProvider() {
+ return provider;
+ }
+
+ /// Registers a listener for states arriving from elsewhere.
+ ///
+ /// #### Parameters
+ ///
+ /// - `l`: the listener
+ public static void addContinuationListener(ContinuityListener l) {
+ if (l != null && !listeners.contains(l)) {
+ listeners.add(l);
+ }
+ }
+
+ /// Removes a listener.
+ ///
+ /// #### Parameters
+ ///
+ /// - `l`: the listener
+ public static void removeContinuationListener(ContinuityListener l) {
+ listeners.remove(l);
+ }
+
+ /// Installs the endpoint that carries state to devices the platform will not reach, and asks
+ /// it immediately for anything newer than what is here.
+ ///
+ /// #### Parameters
+ ///
+ /// - `r`: the relay, or null to stop using one
+ public static void setRelay(StateRelay r) {
+ relay = r;
+ if (r != null) {
+ enable();
+ pollRelay();
+ }
+ }
+
+ /// The installed relay, or null.
+ ///
+ /// #### Returns
+ ///
+ /// the relay
+ public static StateRelay getRelay() {
+ return relay;
+ }
+
+ /// Whether a restorable state found at startup, or arriving from another device, is applied
+ /// automatically. On by default.
+ ///
+ /// Turning it off leaves `restore()` and every listener working exactly as before; what stops
+ /// is the framework acting on its own. Use it when the decision to move the user is always
+ /// the app's.
+ ///
+ /// #### Parameters
+ ///
+ /// - `b`: true to restore automatically
+ public static void setAutoRestore(boolean b) {
+ autoRestore = b;
+ }
+
+ /// Whether automatic restoration is on.
+ ///
+ /// #### Returns
+ ///
+ /// true when on
+ public static boolean isAutoRestore() {
+ return autoRestore;
+ }
+
+ /// Sets the label a receiving device may show before the user accepts a continuation -- "Draft
+ /// to Dana", "Invoice 2031". Update it as the user moves around; it is read at every
+ /// checkpoint.
+ ///
+ /// #### Parameters
+ ///
+ /// - `t`: the label, or null for none
+ public static void setTitle(String t) {
+ title = t;
+ }
+
+ /// The current continuation label, or null.
+ ///
+ /// #### Returns
+ ///
+ /// the label
+ public static String getTitle() {
+ return title;
+ }
+
+ /// How old a stored state may be and still be restored, in milliseconds. Zero, the default,
+ /// means no limit: an app the user opens after a month comes back where they left it, which is
+ /// what they expect of it.
+ ///
+ /// Set it when coming back is only meaningful for a while -- a checkout, a queue position, a
+ /// booking hold.
+ ///
+ /// #### Parameters
+ ///
+ /// - `millis`: the limit, or 0 for none
+ public static void setMaxAge(long millis) {
+ maxAge = millis < 0 ? 0 : millis;
+ }
+
+ /// The staleness limit in milliseconds, or 0 for none.
+ ///
+ /// #### Returns
+ ///
+ /// the limit
+ public static long getMaxAge() {
+ return maxAge;
+ }
+
+ /// This installation's device id, the value that lets a state be recognized as this device's
+ /// own echo when it comes back through a relay. Stable across restarts.
+ ///
+ /// #### Returns
+ ///
+ /// the device id, never null
+ public static String getDeviceId() {
+ if (deviceId == null) {
+ deviceId = loadDeviceId();
+ }
+ return deviceId;
+ }
+
+ // ------------------------------------------------------------------
+ // Saving
+ // ------------------------------------------------------------------
+
+ /// Internal. Called by `com.codename1.router.Navigation` after every change to the navigation
+ /// stack; schedules a checkpoint rather than taking one, so a burst of navigations costs a
+ /// single write.
+ public static void routeStackChanged() {
+ if (!enabled) {
+ return;
+ }
+ dirty = true;
+ if (flushScheduled || !Display.isInitialized()) {
+ return;
+ }
+ flushScheduled = true;
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ flushScheduled = false;
+ if (dirty) {
+ checkpoint();
+ }
+ }
+ });
+ }
+
+ /// Writes the current state now, and offers it to every enabled channel: storage always, the
+ /// platform's continuation where there is one, and the relay if one is set.
+ ///
+ /// Cheap enough to call freely -- the state is a list of paths and a small map -- but it does
+ /// touch storage, so it belongs at the end of a change rather than inside a loop.
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: when the provider returned a payload that cannot cross to
+ /// another device
+ public static void checkpoint() {
+ if (!enabled) {
+ return;
+ }
+ dirty = false;
+ AppState state = capture();
+ if (state == null) {
+ return;
+ }
+ persist(state);
+ publishContinuation(state);
+ publishToRelay(state);
+ }
+
+ /// Builds a state from the route stack and the provider without writing it anywhere. Useful
+ /// for sending one somewhere of your own.
+ ///
+ /// #### Returns
+ ///
+ /// the current state, or null when the framework is not enabled
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: when the provider returned an unrepresentable payload
+ public static AppState capture() {
+ if (!enabled) {
+ return null;
+ }
+ AppState state = new AppState();
+ state.setRoutes(currentRoutes());
+ StateProvider p = provider;
+ if (p != null) {
+ Map payload = null;
+ try {
+ payload = p.saveState();
+ } catch (Throwable t) {
+ // The provider is application code running on a housekeeping path. Its failure
+ // must not take down the navigation that triggered the checkpoint, so the routes
+ // are still saved and the payload is simply absent from this one.
+ Log.e(t);
+ }
+ if (payload != null) {
+ // NOT caught. An unrepresentable value is a programming error with exactly one
+ // correct moment to surface -- here, naming the key -- rather than as a payload
+ // that silently stops arriving on the other device.
+ state.setPayload(payload);
+ }
+ }
+ sequence = nextSequence();
+ state.setDeviceId(getDeviceId())
+ .setSequence(sequence)
+ .setTimestamp(System.currentTimeMillis())
+ .setTitle(title);
+ return state;
+ }
+
+ // ------------------------------------------------------------------
+ // Restoring
+ // ------------------------------------------------------------------
+
+ /// The state waiting to be restored: one that arrived from another device if there is one,
+ /// otherwise the last checkpoint written on this device.
+ ///
+ /// #### Returns
+ ///
+ /// the state, or null when there is nothing to restore or it is older than `getMaxAge()`
+ public static AppState getRestorableState() {
+ if (parked != null) {
+ return parked;
+ }
+ AppState stored = readStored();
+ if (stored == null) {
+ return null;
+ }
+ if (maxAge > 0 && stored.getTimestamp() > 0
+ && System.currentTimeMillis() - stored.getTimestamp() > maxAge) {
+ return null;
+ }
+ return stored;
+ }
+
+ /// Restores whatever `getRestorableState()` offers.
+ ///
+ /// Written to read as "restore, or else begin":
+ ///
+ /// ```java
+ /// public void start() {
+ /// if (!Continuity.restore()) {
+ /// Navigation.navigate("/home");
+ /// }
+ /// }
+ /// ```
+ ///
+ /// #### Returns
+ ///
+ /// true when a form was shown, so the caller should not show its own
+ public static boolean restore() {
+ AppState state = getRestorableState();
+ if (state == null) {
+ return false;
+ }
+ parked = null;
+ return restore(state);
+ }
+
+ /// Restores a specific state: hands its payload to the provider, then replays its route stack.
+ ///
+ /// This is the second half of the "ask first" pattern -- a `ContinuityListener` that returned
+ /// false to hold a state calls this once the user accepts it.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state, or null
+ ///
+ /// #### Returns
+ ///
+ /// true when a form was shown
+ public static boolean restore(AppState state) {
+ if (state == null) {
+ return false;
+ }
+ StateProvider p = provider;
+ if (p != null) {
+ try {
+ // Before the routes, so a form the route table is about to build can read what
+ // the provider stashed while it is being constructed.
+ p.restoreState(state.getPayload());
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ List routes = state.getRoutes();
+ if (routes.isEmpty()) {
+ // Payload-only restoration, which is what an app that does not use @Route gets. The
+ // provider was given everything there is; whether that produced a form is its
+ // business, and saying "no form" here would make the caller show a second one.
+ return false;
+ }
+ try {
+ return Navigation.restoreStack(routes);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Asks the relay for anything newer than what is here, on a background thread. Returns
+ /// immediately.
+ ///
+ /// Worth calling when the app comes back to the foreground: a continuation reaches a nearby
+ /// device on its own, but a relay is only read when something asks it to be.
+ public static void pollRelay() {
+ final StateRelay r = relay;
+ if (r == null || !enabled || !Display.isInitialized()) {
+ return;
+ }
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ AppState fetched = null;
+ try {
+ fetched = r.fetch();
+ } catch (Throwable t) {
+ Log.e(t);
+ return;
+ }
+ if (fetched != null) {
+ deliver(fetched);
+ }
+ }
+ }, "Continuity relay poll").start();
+ }
+
+ /// Forgets everything: the stored checkpoint, any parked arrival, and the activity advertised
+ /// to the user's other devices.
+ ///
+ /// Belongs on your logout path. The advertised activity outlives the app's own screen, so an
+ /// account's work would otherwise stay offered to the devices around it after the user signed
+ /// out.
+ public static void clear() {
+ parked = null;
+ dirty = false;
+ lastSeen.clear();
+ clearContinuation();
+ try {
+ if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) {
+ Storage.getInstance().deleteStorageFile(STORAGE_KEY);
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Internals
+ // ------------------------------------------------------------------
+
+ private static List currentRoutes() {
+ List paths = new ArrayList();
+ List stack;
+ try {
+ stack = Navigation.getStack();
+ } catch (Throwable t) {
+ // Only the call is guarded. Walking the list has to sit outside, because the compiler
+ // inserts a checked cast per element for the generic type -- and a failed cast does
+ // not throw on the iOS virtual machine, so a handler wrapped around one is a handler
+ // that cannot run there. See the ClassCastException note in CLAUDE.md.
+ Log.e(t);
+ return paths;
+ }
+ for (int i = 0; i < stack.size(); i++) {
+ paths.add(stack.get(i).getPath());
+ }
+ return paths;
+ }
+
+ private static void persist(AppState state) {
+ try {
+ Storage.getInstance().writeObject(STORAGE_KEY, state);
+ Preferences.set(PREF_SEQUENCE, sequence);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private static AppState readStored() {
+ try {
+ if (!Display.isInitialized() || !Storage.getInstance().exists(STORAGE_KEY)) {
+ return null;
+ }
+ Object o = Storage.getInstance().readObject(STORAGE_KEY);
+ // instanceof rather than a cast: a failed cast does not throw on the iOS virtual
+ // machine, so the wrong object would be handed to the next instruction instead of
+ // reaching a catch. A stored entry of another shape is possible after a downgrade.
+ if (o instanceof AppState) {
+ return (AppState) o;
+ }
+ return null;
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ private static void publishContinuation(AppState state) {
+ ContinuityBridge b = bridgeInternal();
+ if (b == null) {
+ return;
+ }
+ try {
+ if (!b.isContinuationSupported()) {
+ return;
+ }
+ if (state.isEmpty()) {
+ b.clearContinuation();
+ return;
+ }
+ b.publishContinuation(getActivityType(), state.getTitle(), StateCodec.toMap(state));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private static void clearContinuation() {
+ ContinuityBridge b = bridgeInternal();
+ if (b == null) {
+ return;
+ }
+ try {
+ if (b.isContinuationSupported()) {
+ b.clearContinuation();
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private static void publishToRelay(AppState state) {
+ final StateRelay r = relay;
+ if (r == null || !Display.isInitialized()) {
+ return;
+ }
+ final AppState captured = state;
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ try {
+ r.publish(captured);
+ } catch (Throwable t) {
+ // Logged and dropped. The state is already in storage, and the next
+ // checkpoint carries a superset of it, so retrying this one would only put an
+ // older state on the wire after a newer one.
+ Log.e(t);
+ }
+ }
+ }, "Continuity relay publish").start();
+ }
+
+ /// The activity type this app publishes and answers to, which is the app's package name
+ /// followed by `.continuity`.
+ ///
+ /// Fixed by the build, which declares the same string to the platform in `NSUserActivityTypes`;
+ /// the two have to agree or the operating system refuses to deliver anything. Exposed because
+ /// an app that also publishes activities of its own needs to know which one is this
+ /// framework's, and because it is the first thing to check when a continuation never arrives.
+ ///
+ /// #### Returns
+ ///
+ /// the activity type, never null
+ public static String getActivityType() {
+ String pkg = null;
+ try {
+ pkg = Display.getInstance().getProperty("package_name", null);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ if (pkg == null || pkg.length() == 0) {
+ pkg = "com.codename1.app";
+ }
+ return pkg + ".continuity";
+ }
+
+ /// Routes an arriving state to the application, from whatever channel produced it.
+ static void deliver(final AppState state) {
+ if (!enabled || state == null) {
+ return;
+ }
+ if (getDeviceId().equals(state.getDeviceId())) {
+ // This device's own echo, which a relay returns as a matter of course.
+ return;
+ }
+ synchronized (lastSeen) {
+ Long seen = lastSeen.get(state.getDeviceId());
+ if (seen != null && seen.longValue() >= state.getSequence()) {
+ return;
+ }
+ lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence()));
+ }
+ if (!Display.isInitialized()) {
+ parked = state;
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ dispatch(state);
+ }
+ });
+ }
+
+ private static void dispatch(AppState state) {
+ if (Display.getInstance().getCurrent() == null) {
+ // A continuation can cold-launch the app, and both Apple delegates hand it over while
+ // init/start are still queued. Restoring against no form at all would run the route
+ // table into a display that is not ready, so it waits -- bounded, because a launch
+ // that never produces a form is broken and jumping the user minutes later is worse
+ // than doing nothing.
+ park(state);
+ return;
+ }
+ for (int i = 0; i < listeners.size(); i++) {
+ ContinuityListener l = listeners.get(i);
+ boolean accepted;
+ try {
+ accepted = l.stateReceived(state);
+ } catch (Throwable t) {
+ Log.e(t);
+ continue;
+ }
+ if (!accepted) {
+ // Consumed by the listener: it either handled the state itself or decided the user
+ // must not be moved. Asking the next listener would undo that decision.
+ return;
+ }
+ }
+ if (autoRestore) {
+ restore(state);
+ } else {
+ parked = state;
+ }
+ }
+
+ private static void park(final AppState state) {
+ parked = state;
+ if (waitingForWindow) {
+ return;
+ }
+ waitingForWindow = true;
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS;
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException err) {
+ break;
+ }
+ if (Display.getInstance().getCurrent() != null) {
+ break;
+ }
+ }
+ waitingForWindow = false;
+ if (Display.getInstance().getCurrent() == null) {
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ AppState waiting = parked;
+ if (waiting == state) {
+ parked = null;
+ dispatch(waiting);
+ }
+ }
+ });
+ }
+ }, "Continuity window wait").start();
+ }
+
+ private static String loadDeviceId() {
+ try {
+ String id = Preferences.get(PREF_DEVICE_ID, null);
+ if (id == null || id.length() == 0) {
+ id = Util.getUUID();
+ Preferences.set(PREF_DEVICE_ID, id);
+ }
+ return id;
+ } catch (Throwable t) {
+ Log.e(t);
+ // A device with no readable preferences still has to have an id, or every state it
+ // produces would look like every other device's. Unstable across restarts, which
+ // costs only some duplicate deliveries.
+ return "cn1-" + System.currentTimeMillis();
+ }
+ }
+
+ private static long loadSequence() {
+ try {
+ return Preferences.get(PREF_SEQUENCE, (long) 0);
+ } catch (Throwable t) {
+ Log.e(t);
+ return 0;
+ }
+ }
+
+ private static long nextSequence() {
+ return sequence + 1;
+ }
+
+ /// Test seam: installs a bridge, bypassing platform resolution.
+ ///
+ /// #### Parameters
+ ///
+ /// - `b`: the bridge, or null to resolve from the platform again
+ public static void setBridge(ContinuityBridge b) {
+ bridge = b;
+ bridgeOverridden = b != null;
+ if (b != null && enabled) {
+ try {
+ b.setCallback(new Callback());
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ }
+
+ /// Internal. The resolved platform bridge, for `com.codename1.continuity.sync`, which is a
+ /// package of its own so that its entitlement is earned separately. Application code uses
+ /// `com.codename1.continuity.sync.SyncedStore`.
+ ///
+ /// #### Returns
+ ///
+ /// the bridge, or null when this port has none
+ public static ContinuityBridge bridgeForSyncedStore() {
+ return bridgeInternal();
+ }
+
+ /// Internal. Re-installs the framework's inbound seam on whatever bridge the port now
+ /// returns. Called by a port that swaps its bridge while the app is running, which only the
+ /// simulator does -- a device's bridge is created once and lives as long as the process.
+ public static void refreshBridge() {
+ if (!enabled) {
+ return;
+ }
+ ContinuityBridge b = bridgeInternal();
+ if (b == null) {
+ return;
+ }
+ try {
+ b.setCallback(new Callback());
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ static ContinuityBridge bridgeInternal() {
+ if (bridgeOverridden) {
+ return bridge;
+ }
+ if (!Display.isInitialized()) {
+ return null;
+ }
+ try {
+ return Display.getInstance().getContinuityBridge();
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ /// Test seam: returns the framework to its untouched state.
+ static void reset() {
+ listeners.clear();
+ synchronized (lastSeen) {
+ lastSeen.clear();
+ }
+ provider = null;
+ relay = null;
+ bridge = null;
+ bridgeOverridden = false;
+ enabled = false;
+ autoRestore = true;
+ dirty = false;
+ flushScheduled = false;
+ waitingForWindow = false;
+ deviceId = null;
+ title = null;
+ sequence = 0;
+ maxAge = 0;
+ parked = null;
+ }
+
+ /// The inbound seam handed to the port's bridge.
+ static final class Callback implements ContinuityCallback {
+ public boolean continuationReceived(String activityType, Map userInfo) {
+ if (!enabled || activityType == null || !activityType.equals(getActivityType())) {
+ // Not ours. Answering honestly is what keeps a Handoff or third-party activity
+ // this app never published from being swallowed by a handler that would do
+ // nothing with it.
+ return false;
+ }
+ AppState state = StateCodec.fromMap(userInfo);
+ if (state == null) {
+ return false;
+ }
+ deliver(state);
+ return true;
+ }
+
+ public void syncedStoreChanged() {
+ com.codename1.continuity.sync.SyncedStore.notifyChanged();
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java
new file mode 100644
index 00000000000..10a3b44a96e
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.continuity;
+
+/// Notified when a state arrives from somewhere other than this device's own storage: one of the
+/// user's other devices handed off what they were doing, or a `StateRelay` produced something
+/// newer than what is here.
+///
+/// Registered with `Continuity.addContinuationListener(ContinuityListener)`. Called on the event
+/// dispatch thread, and never for this device's own echo.
+public interface ContinuityListener {
+ /// A state arrived. Return true to let the framework restore it, false to ignore it.
+ ///
+ /// Returning false is the hook for the decisions only the app can make -- that the user is
+ /// midway through a payment and must not be moved, that the state is older than what is on
+ /// screen, that it belongs to a different account than the one signed in here. A listener
+ /// that returns false has consumed the state: nothing is restored and no other listener is
+ /// asked.
+ ///
+ /// Doing the work yourself and returning false is a supported pattern, and is how an app
+ /// prompts before jumping: keep the state, return false, and call
+ /// `Continuity.restore(AppState)` when the user accepts.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state that arrived
+ ///
+ /// #### Returns
+ ///
+ /// true to restore it now
+ boolean stateReceived(AppState state);
+}
diff --git a/CodenameOne/src/com/codename1/continuity/RestStateRelay.java b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java
new file mode 100644
index 00000000000..e38d98757eb
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java
@@ -0,0 +1,144 @@
+/*
+ * 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.continuity;
+
+import com.codename1.io.rest.RequestBuilder;
+import com.codename1.io.rest.Response;
+import com.codename1.io.rest.Rest;
+
+import java.io.IOException;
+
+/// A `StateRelay` over your own HTTPS endpoint, which is all most applications need.
+///
+/// ```java
+/// Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") {
+/// protected String getToken() {
+/// return session.getAccessToken();
+/// }
+/// });
+/// ```
+///
+/// #### The contract
+///
+/// Two requests against the one URL you supply:
+///
+/// - `POST` with the state as a JSON body and `Content-Type: application/json`. Store it against
+/// the signed-in user, replacing whatever you held for them. Any 2xx means stored.
+/// - `GET`, answering with the newest state you hold for that user as the same JSON, or an empty
+/// body when you hold none. A 404 also means none.
+///
+/// The JSON is exactly what `StateCodec.toJson(AppState)` produces, and it is a closed shape: your
+/// endpoint stores and returns the document, and never needs to look inside it.
+///
+/// #### Identity is yours
+///
+/// Which states belong to the same person is the one question the framework cannot answer, which
+/// is why the token comes from `getToken()` rather than from a constructor: it is read at each
+/// request, so a session that refreshes its token is followed automatically. Return null for an
+/// endpoint that identifies the user some other way -- a cookie, mutual TLS -- and the header is
+/// simply omitted.
+///
+/// #### Threading
+///
+/// Both methods are called from a background thread and block, which is what the framework
+/// expects of a relay. `getToken()` is called on that same thread, so it must not wait on the
+/// event dispatch thread.
+public class RestStateRelay implements StateRelay {
+ private final String url;
+
+ /// Creates a relay against an HTTPS endpoint.
+ ///
+ /// #### Parameters
+ ///
+ /// - `url`: the endpoint, which must be HTTPS
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: when the URL is null, empty or not HTTPS
+ public RestStateRelay(String url) {
+ if (url == null || url.length() == 0) {
+ throw new IllegalArgumentException("A continuity relay needs an endpoint URL.");
+ }
+ if (url.length() < 8 || !"https://".equals(url.substring(0, 8).toLowerCase())) {
+ // Refused rather than passed on. The bearer token goes out on every request and the
+ // payload is a description of what the user is doing on their other device; an
+ // "http://" typo would put both on the network in the clear wherever a cleartext
+ // policy still allows it.
+ throw new IllegalArgumentException("A continuity relay endpoint must be HTTPS; got \""
+ + url + "\".");
+ }
+ this.url = url;
+ }
+
+ /// The endpoint this relay talks to.
+ ///
+ /// #### Returns
+ ///
+ /// the URL
+ public String getUrl() {
+ return url;
+ }
+
+ /// The bearer token to present, read once per request. The default returns null, which sends
+ /// no `Authorization` header.
+ ///
+ /// #### Returns
+ ///
+ /// the token, or null for none
+ protected String getToken() {
+ return null;
+ }
+
+ public void publish(AppState state) throws IOException {
+ Response response = auth(Rest.post(url).jsonContent()
+ .body(StateCodec.toJson(state))).getAsString();
+ int code = response.getResponseCode();
+ if (code < 200 || code > 299) {
+ throw new IOException("The continuity relay refused the state: HTTP " + code
+ + (response.getResponseErrorMessage() == null ? ""
+ : " " + response.getResponseErrorMessage()));
+ }
+ }
+
+ public AppState fetch() throws IOException {
+ Response response = auth(Rest.get(url).jsonContent()).getAsString();
+ int code = response.getResponseCode();
+ if (code == 404 || code == 204) {
+ // Not an error. An endpoint that holds nothing for this user yet is the ordinary
+ // state of affairs on a first run, and throwing here would log a failure on every
+ // launch until the user's second device wrote something.
+ return null;
+ }
+ if (code < 200 || code > 299) {
+ throw new IOException("The continuity relay refused to answer: HTTP " + code
+ + (response.getResponseErrorMessage() == null ? ""
+ : " " + response.getResponseErrorMessage()));
+ }
+ return StateCodec.fromJson(response.getResponseData());
+ }
+
+ private RequestBuilder auth(RequestBuilder b) {
+ String token = getToken();
+ return token == null || token.length() == 0 ? b : b.bearer(token);
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java
new file mode 100644
index 00000000000..c9535e95264
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java
@@ -0,0 +1,272 @@
+/*
+ * 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.continuity;
+
+import com.codename1.io.JSONParser;
+import com.codename1.io.JSONWriter;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/// Turns an `AppState` into the two forms it has to travel in, and refuses payloads that cannot
+/// make the trip.
+///
+/// The two forms are deliberately different. A *continuation* is handed to the operating system,
+/// which stores it as a property list and may deliver it to another device, so it is a nested map
+/// of plist-representable values. A *relay* payload crosses a network to a device that may not be
+/// an Apple one at all, so it is JSON. Both are lossless for the value types the payload admits,
+/// which is the whole reason the payload admits so few.
+///
+/// This class is public so that a `StateRelay` written by an application can use the same wire
+/// format the built-in one does, and so tests can assert on it.
+public final class StateCodec {
+ private static final String KEY_ROUTES = "routes";
+ private static final String KEY_PAYLOAD = "payload";
+ private static final String KEY_DEVICE = "device";
+ private static final String KEY_TITLE = "title";
+ private static final String KEY_SEQUENCE = "seq";
+ private static final String KEY_TIMESTAMP = "ts";
+
+ private StateCodec() {
+ }
+
+ /// Renders a state as the nested map an operating system can carry between devices.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state, must not be null
+ ///
+ /// #### Returns
+ ///
+ /// a map of plist-representable values
+ public static Map toMap(AppState state) {
+ Map m = new HashMap();
+ m.put(KEY_ROUTES, new ArrayList(state.getRoutes()));
+ m.put(KEY_PAYLOAD, new HashMap(state.getPayload()));
+ m.put(KEY_DEVICE, state.getDeviceId());
+ if (state.getTitle() != null) {
+ m.put(KEY_TITLE, state.getTitle());
+ }
+ // Written as strings rather than as numbers. Both survive a property list, but a JSON
+ // round trip through JSONParser reads every number back as a Double, and a millisecond
+ // timestamp is past the range a double represents exactly -- so the same two fields would
+ // come back changed on the relay path and unchanged on the continuation path. One
+ // encoding for both keeps a state comparable with itself however it arrived.
+ m.put(KEY_SEQUENCE, Long.toString(state.getSequence()));
+ m.put(KEY_TIMESTAMP, Long.toString(state.getTimestamp()));
+ return m;
+ }
+
+ /// Rebuilds a state from the map form. Unknown keys are ignored, so a newer build of the app
+ /// on another device can add fields without breaking this one.
+ ///
+ /// #### Parameters
+ ///
+ /// - `m`: the map, or null
+ ///
+ /// #### Returns
+ ///
+ /// the state, or null when the map is null or carries nothing recognizable
+ public static AppState fromMap(Map m) {
+ if (m == null) {
+ return null;
+ }
+ AppState state = new AppState();
+ Object routes = m.get(KEY_ROUTES);
+ if (routes instanceof List) {
+ List paths = new ArrayList();
+ for (Iterator> i = ((List>) routes).iterator(); i.hasNext();) {
+ Object path = i.next();
+ if (path instanceof String) {
+ paths.add((String) path);
+ }
+ }
+ state.setRoutes(paths);
+ }
+ Object payload = m.get(KEY_PAYLOAD);
+ if (payload instanceof Map) {
+ Map copy = new HashMap();
+ Map, ?> read = (Map, ?>) payload;
+ for (Iterator extends Map.Entry, ?>> i = read.entrySet().iterator();
+ i.hasNext();) {
+ Map.Entry, ?> entry = i.next();
+ if (entry.getKey() instanceof String) {
+ copy.put((String) entry.getKey(), entry.getValue());
+ }
+ }
+ // Not validated on the way in. This map came from another device, and refusing it
+ // would turn that device's mistake into an exception on this one at a moment the user
+ // cannot connect to anything they did.
+ state.setPayloadUnchecked(copy);
+ }
+ Object device = m.get(KEY_DEVICE);
+ if (device instanceof String) {
+ state.setDeviceId((String) device);
+ }
+ Object title = m.get(KEY_TITLE);
+ if (title instanceof String) {
+ state.setTitle((String) title);
+ }
+ state.setSequence(asLong(m.get(KEY_SEQUENCE)));
+ state.setTimestamp(asLong(m.get(KEY_TIMESTAMP)));
+ return state;
+ }
+
+ /// Renders a state as JSON, for a relay.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state, must not be null
+ ///
+ /// #### Returns
+ ///
+ /// the JSON document
+ public static String toJson(AppState state) {
+ return JSONWriter.toJson(toMap(state));
+ }
+
+ /// Parses the JSON form.
+ ///
+ /// #### Parameters
+ ///
+ /// - `json`: the document, or null
+ ///
+ /// #### Returns
+ ///
+ /// the state, or null when the document is null, empty or not an object
+ ///
+ /// #### Throws
+ ///
+ /// - `java.io.IOException`: when the document is malformed
+ public static AppState fromJson(String json) throws IOException {
+ if (json == null || json.trim().length() == 0) {
+ return null;
+ }
+ return fromMap(JSONParser.parseJSON(json));
+ }
+
+ /// Throws when any value in the map could not survive being written to a property list, sent
+ /// as JSON and read back by another build of the app on another device.
+ ///
+ /// The admitted types are `String`, `Integer`, `Long`, `Double`, `Boolean`, and `List` and
+ /// `Map` of those. `Map` keys must be strings, because neither destination format has any
+ /// other kind of key.
+ ///
+ /// #### Parameters
+ ///
+ /// - `payload`: the payload, or null
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: naming the path to the first offending value
+ public static void requireRepresentable(Map payload) {
+ if (payload == null) {
+ return;
+ }
+ for (Iterator> i = payload.entrySet().iterator();
+ i.hasNext();) {
+ Map.Entry entry = i.next();
+ if (entry.getKey() == null) {
+ throw new IllegalArgumentException("A continuity payload cannot have a null key.");
+ }
+ check(entry.getValue(), entry.getKey(), 0);
+ }
+ }
+
+ /// The number of characters the rendered JSON form occupies, which is the closest portable
+ /// stand-in for what a payload costs on any of the transports.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state
+ ///
+ /// #### Returns
+ ///
+ /// the encoded size in characters
+ public static int encodedSize(AppState state) {
+ return toJson(state).length();
+ }
+
+ private static void check(Object value, String path, int depth) {
+ if (depth > 16) {
+ // A payload cannot legitimately be this deep, and a cycle looks exactly like a very
+ // deep tree until the stack runs out. Refused with the path so the shape is findable.
+ throw new IllegalArgumentException("The continuity payload at \"" + path
+ + "\" nests more than 16 levels deep, or contains a cycle. Neither a property "
+ + "list nor JSON can represent a cycle.");
+ }
+ if (value == null || value instanceof String || value instanceof Integer
+ || value instanceof Long || value instanceof Double || value instanceof Boolean) {
+ return;
+ }
+ if (value instanceof List) {
+ List> list = (List>) value;
+ for (int i = 0; i < list.size(); i++) {
+ check(list.get(i), path + "[" + i + "]", depth + 1);
+ }
+ return;
+ }
+ if (value instanceof Map) {
+ Map, ?> map = (Map, ?>) value;
+ for (Iterator extends Map.Entry, ?>> i = map.entrySet().iterator();
+ i.hasNext();) {
+ Map.Entry, ?> entry = i.next();
+ Object key = entry.getKey();
+ if (!(key instanceof String)) {
+ throw new IllegalArgumentException("The continuity payload at \"" + path
+ + "\" has a map key of type "
+ + (key == null ? "null" : key.getClass().getName())
+ + ". Only string keys can be written to a property list or to JSON.");
+ }
+ check(entry.getValue(), path + "." + key, depth + 1);
+ }
+ return;
+ }
+ throw new IllegalArgumentException("The continuity payload at \"" + path + "\" is a "
+ + value.getClass().getName() + ". A continuity payload has to survive being "
+ + "written to a property list and delivered to another device, possibly running a "
+ + "different build of this app, so it admits only String, Integer, Long, Double, "
+ + "Boolean, and List and Map of those. Convert this value before adding it.");
+ }
+
+ private static long asLong(Object o) {
+ if (o instanceof String) {
+ try {
+ return Long.parseLong(((String) o).trim());
+ } catch (NumberFormatException err) {
+ return 0;
+ }
+ }
+ // Never a cast: on ParparVM a failed CHECKCAST does not throw, so the guarded instanceof
+ // is the only portable way to ask. A relay written before the string encoding, or a
+ // hand-written server, can still send a number here.
+ if (o instanceof Number) {
+ return ((Number) o).longValue();
+ }
+ return 0;
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/StateProvider.java b/CodenameOne/src/com/codename1/continuity/StateProvider.java
new file mode 100644
index 00000000000..522fe99338d
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateProvider.java
@@ -0,0 +1,60 @@
+/*
+ * 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.continuity;
+
+import java.util.Map;
+
+/// Supplies and consumes the half of the application state the framework cannot work out for
+/// itself.
+///
+/// The framework already knows the route stack. What it cannot know is the scroll position, the
+/// half-typed message, the selected tab, the id of the record being edited -- so this is where
+/// those go.
+///
+/// Both methods run on the event dispatch thread. `saveState` is called whenever the framework
+/// takes a checkpoint, which can be often, so it should read fields rather than compute; anything
+/// expensive belongs in a field the app updates as the user works.
+public interface StateProvider {
+ /// The application's share of the state. May return null or an empty map when there is
+ /// nothing to add, in which case only the routes are carried.
+ ///
+ /// The returned map is restricted to `String`, `Integer`, `Long`, `Double`, `Boolean`, and
+ /// `List` and `Map` of those -- see `AppState` for why. Returning anything else fails the
+ /// checkpoint with a message naming the key.
+ ///
+ /// #### Returns
+ ///
+ /// the payload, or null
+ Map saveState();
+
+ /// Applies a payload this provider previously produced, on this device or another one.
+ ///
+ /// Called before the restored screens are shown, so a form built by the route table can read
+ /// what was put here during its own construction. When the app has no routes, this is the
+ /// whole of restoration and the provider is responsible for showing a form.
+ ///
+ /// #### Parameters
+ ///
+ /// - `payload`: the payload, never null and possibly empty
+ void restoreState(Map payload);
+}
diff --git a/CodenameOne/src/com/codename1/continuity/StateRelay.java b/CodenameOne/src/com/codename1/continuity/StateRelay.java
new file mode 100644
index 00000000000..f0e5e34f227
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateRelay.java
@@ -0,0 +1,63 @@
+/*
+ * 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.continuity;
+
+import java.io.IOException;
+
+/// Carries state between devices the platform will not carry it between -- an iPhone and an
+/// Android tablet, two devices that are never in the same room, a phone and the web build.
+///
+/// Codename One ships no server for this. A relay is the application's own endpoint, which is
+/// also the only honest arrangement: the relay has to know which states belong to the same
+/// *person*, and that is the app's account system, not the framework's. `RestStateRelay` covers
+/// the common case over HTTPS; implement this interface directly for anything else.
+///
+/// Both methods are called from a background thread and may block. Neither is called on the event
+/// dispatch thread, so ordinary blocking `com.codename1.io` code is correct here.
+public interface StateRelay {
+ /// Sends a state. Called after each checkpoint, so implementations that talk to a slow
+ /// endpoint should coalesce rather than send every one.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state to send
+ ///
+ /// #### Throws
+ ///
+ /// - `java.io.IOException`: when the send failed; the framework logs it and keeps the state
+ /// for the next attempt
+ void publish(AppState state) throws IOException;
+
+ /// Asks for the newest state this user has on any device. Returning this device's own most
+ /// recent state is fine and expected -- the framework recognizes its own echo by device id and
+ /// sequence, and ignores it.
+ ///
+ /// #### Returns
+ ///
+ /// the state, or null when the endpoint has nothing
+ ///
+ /// #### Throws
+ ///
+ /// - `java.io.IOException`: when the fetch failed
+ AppState fetch() throws IOException;
+}
diff --git a/CodenameOne/src/com/codename1/continuity/package-info.java b/CodenameOne/src/com/codename1/continuity/package-info.java
new file mode 100644
index 00000000000..100150be1e3
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+/// Saves what the user was doing and brings it back -- after the operating system kills the app,
+/// and on the other devices that person owns.
+///
+/// Start at `Continuity`. The framework already knows the `com.codename1.router.Navigation` stack,
+/// so an app whose screens carry `@Route` gets them restored with no code; a `StateProvider` adds
+/// whatever else matters. `AppState` is the snapshot the two halves make, and it is the same value
+/// that is written to storage, advertised to a nearby device and sent through a `StateRelay`.
+///
+/// Referencing this package is what makes the build declare the activity type and compile the
+/// native continuation handling in. Its sibling `com.codename1.continuity.sync` is separate
+/// because it costs an entitlement on iOS.
+///
+/// See the State Restoration and Continuity chapter of the developer guide for the platform
+/// capability table, the build hints and the relay contract.
+package com.codename1.continuity;
diff --git a/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java
new file mode 100644
index 00000000000..402e4d107e4
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java
@@ -0,0 +1,101 @@
+/*
+ * 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.continuity.spi;
+
+import java.util.Map;
+
+/// The platform seam of the continuity framework, implemented by ports and returned from
+/// `CodenameOneImplementation.getContinuityBridge()`. A null bridge -- the base implementation --
+/// leaves saving and restoring state on this device working, because that half is pure
+/// `com.codename1.io.Storage`, and makes every cross-device capability report itself unsupported.
+///
+/// Two independent capabilities live behind one bridge because a port that has either almost
+/// always has both, and an app asks about them separately anyway:
+///
+/// - *Continuation* advertises what the user is doing so a second device they own can pick it up
+/// while the two are together. On Apple platforms this is an `NSUserActivity`; nothing else
+/// implements it, and nothing else is expected to.
+/// - *The synced store* is a small key/value store the platform carries between the user's
+/// devices without them being near each other.
+///
+/// Everything crosses this boundary as data -- strings and plist-representable maps -- never as
+/// live model objects, because on Apple platforms the payload is handed to the operating system
+/// and may be delivered to a different device, and a different build of the app, than the one that
+/// produced it.
+public interface ContinuityBridge {
+ /// Returns true when this port can advertise the user's current activity to their other
+ /// devices.
+ boolean isContinuationSupported();
+
+ /// Advertises the user's current activity, replacing whatever was advertised before.
+ ///
+ /// The payload has already been validated as representable and within the platform's size
+ /// budget by the time it arrives here.
+ ///
+ /// #### Parameters
+ ///
+ /// - `activityType`: the reverse-DNS type the build declared
+ /// - `title`: a human readable label the receiving device may show, or null
+ /// - `userInfo`: the state, as strings, numbers, booleans, lists and maps of those
+ void publishContinuation(String activityType, String title, Map userInfo);
+
+ /// Withdraws the advertised activity. Nothing is being continued after this returns.
+ void clearContinuation();
+
+ /// Returns true when this port has a key/value store the platform syncs between the user's
+ /// devices.
+ boolean isSyncedStoreSupported();
+
+ /// Writes a value to the synced store, replacing any previous value for the key.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key
+ /// - `value`: the value
+ void syncedStorePut(String key, String value);
+
+ /// Reads a value from the synced store.
+ ///
+ /// #### Returns
+ ///
+ /// the value, or null when the key is absent
+ String syncedStoreGet(String key);
+
+ /// Removes a key from the synced store. Removing an absent key does nothing.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key
+ void syncedStoreRemove(String key);
+
+ /// Every key currently in the synced store, in no particular order. Never null.
+ String[] syncedStoreKeys();
+
+ /// Installs the framework's inbound seam. Called once during initialization, before any other
+ /// method on this bridge; ports must retain it and may call it from any thread.
+ ///
+ /// #### Parameters
+ ///
+ /// - `callback`: the seam, never null
+ void setCallback(ContinuityCallback callback);
+}
diff --git a/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java b/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java
new file mode 100644
index 00000000000..0178400cc7d
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.continuity.spi;
+
+import java.util.Map;
+
+/// The framework's inbound seam, handed to every `ContinuityBridge` during initialization. Ports
+/// call it when the platform delivers something; they never dispatch to application code
+/// themselves.
+///
+/// Both methods may be called from any thread, including before the application has a form on
+/// screen: on Apple platforms a continuation can cold-launch the app, and the operating system
+/// hands it over while the virtual machine is still starting. The framework holds such a delivery
+/// until there is somewhere to show it, so implementations must not try to do that themselves.
+public interface ContinuityCallback {
+ /// A continuation arrived from one of the user's other devices.
+ ///
+ /// #### Parameters
+ ///
+ /// - `activityType`: the reverse-DNS type it arrived under
+ /// - `userInfo`: the payload, as strings, numbers, booleans, lists and maps of those
+ ///
+ /// #### Returns
+ ///
+ /// true when the application claimed it, so the port can answer the platform honestly rather
+ /// than swallowing activities this app never published
+ boolean continuationReceived(String activityType, Map userInfo);
+
+ /// The synced store changed underneath the app, because another of the user's devices wrote
+ /// to it. Carries no values: the framework re-reads what it needs.
+ void syncedStoreChanged();
+}
diff --git a/CodenameOne/src/com/codename1/continuity/spi/package-info.java b/CodenameOne/src/com/codename1/continuity/spi/package-info.java
new file mode 100644
index 00000000000..9e64f0fb681
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/spi/package-info.java
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+/// The platform seam of the continuity framework. Ports implement `ContinuityBridge` and the
+/// framework hands each one a `ContinuityCallback` to deliver through.
+///
+/// Application code uses `com.codename1.continuity.Continuity` and never these types.
+package com.codename1.continuity.spi;
diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java
new file mode 100644
index 00000000000..6e2c727c195
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java
@@ -0,0 +1,259 @@
+/*
+ * 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.continuity.sync;
+
+import com.codename1.continuity.Continuity;
+import com.codename1.continuity.spi.ContinuityBridge;
+import com.codename1.io.Log;
+import com.codename1.ui.Display;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/// A small key/value store the platform carries between the devices one person signed in to,
+/// without them ever being in the same room.
+///
+/// This is the slow, patient half of continuity. `com.codename1.continuity.Continuity` hands the
+/// current activity to a device that is *here, now*; this keeps a handful of durable settings --
+/// which theme, which sort order, which tutorial they already dismissed, the id of the document
+/// they are working through -- in step across everything they own.
+///
+/// ```java
+/// SyncedStore.put("sortOrder", "byDate");
+/// String order = SyncedStore.get("sortOrder", "byName");
+/// ```
+///
+/// #### What it is not
+///
+/// Not storage. Not a database, not a cache, and not a place for anything the app cannot cheerfully
+/// do without: the platform decides when to sync, the user can turn the whole mechanism off, and a
+/// device that has never been online has an empty store. Treat every read as "the value, or the
+/// default" -- which is why there is no read without a default.
+///
+/// Not secret. The contents leave the device and are held by the platform on the user's behalf.
+/// Credentials belong in `com.codename1.security.SecureStorage`.
+///
+/// Not large. The platform imposes a total size and a key count, both small; `put` reports a
+/// failure to write rather than pretending it stored something.
+///
+/// #### What it costs
+///
+/// Referencing this package is what makes an iOS build ask for the entitlement that gives the app
+/// a synced store, which in turn requires the capability to be enabled on the App ID. That is why
+/// it is a package of its own: an app that wants continuation to a nearby device and nothing else
+/// should not have to arrange an entitlement to get it. Where the platform has no such store --
+/// Android, desktop, the browser -- `isSupported()` is false and every call here is an inert
+/// no-op, so the sensible shape is a synced value with a local default behind it.
+public final class SyncedStore {
+ private static final List listeners = new ArrayList();
+
+ private SyncedStore() {
+ }
+
+ /// Whether this platform has a store that follows the user between devices.
+ ///
+ /// #### Returns
+ ///
+ /// true when the store is available
+ public static boolean isSupported() {
+ ContinuityBridge b = bridge();
+ try {
+ return b != null && b.isSyncedStoreSupported();
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Writes a value, replacing any previous value for the key.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key, must not be null or empty
+ /// - `value`: the value, must not be null; use `remove(String)` to delete
+ ///
+ /// #### Returns
+ ///
+ /// true when the value was written; false when the store is unavailable or the platform
+ /// refused it, which is what a full store looks like
+ public static boolean put(String key, String value) {
+ requireKey(key);
+ if (value == null) {
+ throw new IllegalArgumentException("A synced store value cannot be null. Use "
+ + "SyncedStore.remove(\"" + key + "\") to delete the key.");
+ }
+ ContinuityBridge b = bridge();
+ if (b == null) {
+ return false;
+ }
+ try {
+ if (!b.isSyncedStoreSupported()) {
+ return false;
+ }
+ b.syncedStorePut(key, value);
+ return true;
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Reads a value.
+ ///
+ /// There is no overload without a default on purpose: the store is genuinely empty on a device
+ /// that has not synced yet, so every read has to have an answer for that.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key, must not be null or empty
+ /// - `def`: what to return when the key is absent or the store is unavailable
+ ///
+ /// #### Returns
+ ///
+ /// the value, or `def`
+ public static String get(String key, String def) {
+ requireKey(key);
+ ContinuityBridge b = bridge();
+ if (b == null) {
+ return def;
+ }
+ try {
+ if (!b.isSyncedStoreSupported()) {
+ return def;
+ }
+ String value = b.syncedStoreGet(key);
+ return value == null ? def : value;
+ } catch (Throwable t) {
+ Log.e(t);
+ return def;
+ }
+ }
+
+ /// Deletes a key. Deleting an absent key does nothing.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key, must not be null or empty
+ public static void remove(String key) {
+ requireKey(key);
+ ContinuityBridge b = bridge();
+ if (b == null) {
+ return;
+ }
+ try {
+ if (b.isSyncedStoreSupported()) {
+ b.syncedStoreRemove(key);
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ /// Every key currently in the store, in no particular order.
+ ///
+ /// #### Returns
+ ///
+ /// the keys, never null and empty when the store is unavailable
+ public static String[] keys() {
+ ContinuityBridge b = bridge();
+ if (b == null) {
+ return new String[0];
+ }
+ try {
+ if (!b.isSyncedStoreSupported()) {
+ return new String[0];
+ }
+ String[] k = b.syncedStoreKeys();
+ return k == null ? new String[0] : k;
+ } catch (Throwable t) {
+ Log.e(t);
+ return new String[0];
+ }
+ }
+
+ /// Registers a listener for changes made on the user's other devices.
+ ///
+ /// #### Parameters
+ ///
+ /// - `l`: the listener
+ public static void addChangeListener(SyncedStoreListener l) {
+ if (l != null && !listeners.contains(l)) {
+ listeners.add(l);
+ }
+ // Enabling is what installs the callback the port delivers change notifications through.
+ // An app that only ever uses the synced store never touches Continuity itself, and would
+ // otherwise register a listener nothing could ever reach.
+ Continuity.enable();
+ }
+
+ /// Removes a listener.
+ ///
+ /// #### Parameters
+ ///
+ /// - `l`: the listener
+ public static void removeChangeListener(SyncedStoreListener l) {
+ listeners.remove(l);
+ }
+
+ /// Internal. Invoked by the continuity framework when a port reports that the store changed
+ /// underneath the app. Application code registers a `SyncedStoreListener` instead.
+ public static void notifyChanged() {
+ if (listeners.isEmpty() || !Display.isInitialized()) {
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ // Copied before iterating: a listener that reacts to a change by unregistering
+ // itself is ordinary, and would otherwise mutate the list being walked.
+ List snapshot =
+ new ArrayList(listeners);
+ for (int i = 0; i < snapshot.size(); i++) {
+ // Read before the try, not inside it: the compiler inserts a checked cast for
+ // the generic element type, and a failed cast does not throw on the iOS
+ // virtual machine -- so a handler wrapped around one cannot run there.
+ SyncedStoreListener l = snapshot.get(i);
+ try {
+ l.storeChanged();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ }
+ });
+ }
+
+ private static void requireKey(String key) {
+ if (key == null || key.length() == 0) {
+ throw new IllegalArgumentException("A synced store key cannot be null or empty.");
+ }
+ }
+
+ private static ContinuityBridge bridge() {
+ return Continuity.bridgeForSyncedStore();
+ }
+
+ /// Test seam: forgets every registered listener.
+ static void reset() {
+ listeners.clear();
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java
new file mode 100644
index 00000000000..d35695975bf
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java
@@ -0,0 +1,33 @@
+/*
+ * 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.continuity.sync;
+
+/// Notified when the synced store changed because another of the user's devices wrote to it.
+///
+/// Called on the event dispatch thread. It carries no values -- read what you need with
+/// `SyncedStore.get(String, String)`, because several keys can change together and the platform
+/// does not always say which.
+public interface SyncedStoreListener {
+ /// The store changed on another device.
+ void storeChanged();
+}
diff --git a/CodenameOne/src/com/codename1/continuity/sync/package-info.java b/CodenameOne/src/com/codename1/continuity/sync/package-info.java
new file mode 100644
index 00000000000..0bbac6f63c6
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/sync/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+/// A small key/value store the platform carries between the devices one person is signed in to,
+/// for the handful of durable choices that should follow them everywhere.
+///
+/// Start at `SyncedStore`. This is a package of its own, rather than part of
+/// `com.codename1.continuity`, because referencing it is what makes an iOS build ask for the
+/// entitlement that grants a synced store -- and an app that only wants to hand work to the device
+/// in the user's other hand should not have to arrange one.
+package com.codename1.continuity.sync;
diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
index 3db0273961c..dc18c153587 100644
--- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
+++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java
@@ -6450,6 +6450,20 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri
return null;
}
+ /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the
+ /// user's current activity to their other devices and to reach the platform's synced key/value
+ /// store. Ports supporting either capability override this; the base implementation returns
+ /// null, which leaves saving and restoring state on this device working -- that half is pure
+ /// `com.codename1.io.Storage` -- and makes every cross-device capability report itself
+ /// unsupported.
+ ///
+ /// #### Returns
+ ///
+ /// the continuity bridge, or null when unsupported
+ public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() {
+ return null;
+ }
+
/// Returns the platform bridge used by the `com.codename1.intents` API to expose the
/// application's capabilities to the system -- assistant intents, app shortcuts and device
/// search. Ports supporting intents override this; the base implementation returns null, which
diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java
new file mode 100644
index 00000000000..c1c294f9497
--- /dev/null
+++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java
@@ -0,0 +1,233 @@
+/*
+ * 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.impl.continuity;
+
+import com.codename1.continuity.spi.ContinuityBridge;
+import com.codename1.continuity.spi.ContinuityCallback;
+import com.codename1.io.Log;
+import com.codename1.io.Preferences;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/// A simulated continuity platform, used by the simulator, the desktop builds and the unit tests.
+///
+/// A simulation rather than nothing, for the reason the call and nearby bridges carry one: almost
+/// everything an app does with continuity -- deciding what belongs in the payload, prompting before
+/// a jump, rebuilding a screen from a route -- has nothing to do with the operating system that
+/// carries the state, and a port that reported nothing would make all of it testable only on a
+/// pair of phones.
+///
+/// It keeps the last published activity in memory so the Simulate menu can show what the app is
+/// offering, and it can hand that activity straight back through `simulateArrival()` -- which is
+/// what "continue this on another device" is, minus the second device. The synced store is real
+/// within one machine: it is backed by `com.codename1.io.Preferences`, so it survives a simulator
+/// restart the way the platform store survives a device one.
+public class LocalContinuityBridge implements ContinuityBridge {
+ /// Prefix for the simulated synced store's keys inside `Preferences`.
+ private static final String PREFIX = "CN1$SyncedStore$";
+
+ /// The list of keys, kept beside them because `Preferences` cannot be enumerated.
+ private static final String INDEX = "CN1$SyncedStoreKeys";
+
+ private ContinuityCallback callback;
+ private String publishedType;
+ private String publishedTitle;
+ private Map publishedInfo;
+
+ public void setCallback(ContinuityCallback c) {
+ callback = c;
+ }
+
+ public boolean isContinuationSupported() {
+ return true;
+ }
+
+ public void publishContinuation(String activityType, String title,
+ Map userInfo) {
+ publishedType = activityType;
+ publishedTitle = title;
+ publishedInfo = userInfo == null ? null : new HashMap(userInfo);
+ }
+
+ public void clearContinuation() {
+ publishedType = null;
+ publishedTitle = null;
+ publishedInfo = null;
+ }
+
+ /// The activity type currently advertised, or null when nothing is.
+ ///
+ /// #### Returns
+ ///
+ /// the type
+ public String getPublishedType() {
+ return publishedType;
+ }
+
+ /// The label currently advertised, or null.
+ ///
+ /// #### Returns
+ ///
+ /// the label
+ public String getPublishedTitle() {
+ return publishedTitle;
+ }
+
+ /// The payload currently advertised, or null when nothing is.
+ ///
+ /// #### Returns
+ ///
+ /// a copy of the payload
+ public Map getPublishedInfo() {
+ return publishedInfo == null ? null : new HashMap(publishedInfo);
+ }
+
+ /// Delivers the currently advertised activity back to the app as though it had arrived from
+ /// another device, which is what the Simulate menu's "continue on this device" does.
+ ///
+ /// The device id inside the payload is rewritten first. Without that the framework would
+ /// recognize the state as this device's own echo and correctly ignore it, and the menu item
+ /// would appear to do nothing.
+ ///
+ /// #### Returns
+ ///
+ /// true when there was an activity to deliver and the app claimed it
+ public boolean simulateArrival() {
+ if (publishedType == null || publishedInfo == null) {
+ return false;
+ }
+ Map copy = new HashMap(publishedInfo);
+ copy.put("device", "simulated-device");
+ return simulateArrival(publishedType, copy);
+ }
+
+ /// Delivers an arbitrary activity, for tests that build their own.
+ ///
+ /// #### Parameters
+ ///
+ /// - `activityType`: the type it arrives under
+ /// - `userInfo`: the payload
+ ///
+ /// #### Returns
+ ///
+ /// true when the app claimed it
+ public boolean simulateArrival(String activityType, Map userInfo) {
+ ContinuityCallback c = callback;
+ if (c == null) {
+ return false;
+ }
+ try {
+ return c.continuationReceived(activityType, userInfo);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Synced store
+ // ------------------------------------------------------------------
+
+ public boolean isSyncedStoreSupported() {
+ return true;
+ }
+
+ public void syncedStorePut(String key, String value) {
+ Preferences.set(PREFIX + key, value);
+ List keys = indexKeys();
+ if (!keys.contains(key)) {
+ keys.add(key);
+ writeIndex(keys);
+ }
+ }
+
+ public String syncedStoreGet(String key) {
+ return Preferences.get(PREFIX + key, null);
+ }
+
+ public void syncedStoreRemove(String key) {
+ Preferences.delete(PREFIX + key);
+ List keys = indexKeys();
+ if (keys.remove(key)) {
+ writeIndex(keys);
+ }
+ }
+
+ public String[] syncedStoreKeys() {
+ List keys = indexKeys();
+ return keys.toArray(new String[keys.size()]);
+ }
+
+ /// Reports a change made "on another device", which the Simulate menu uses to exercise an
+ /// app's `SyncedStoreListener` without a second machine.
+ public void simulateStoreChange() {
+ ContinuityCallback c = callback;
+ if (c == null) {
+ return;
+ }
+ try {
+ c.syncedStoreChanged();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private List indexKeys() {
+ List keys = new ArrayList();
+ String raw = Preferences.get(INDEX, "");
+ if (raw == null || raw.length() == 0) {
+ return keys;
+ }
+ // Newline separated because a synced store key is an application-chosen string and the
+ // separators one might reach for first -- comma, semicolon, space -- are all plausible
+ // inside one. A newline is not, and put() is the only writer.
+ int start = 0;
+ while (start <= raw.length()) {
+ int end = raw.indexOf('\n', start);
+ if (end < 0) {
+ end = raw.length();
+ }
+ String key = raw.substring(start, end);
+ if (key.length() > 0 && !keys.contains(key)) {
+ keys.add(key);
+ }
+ start = end + 1;
+ }
+ return keys;
+ }
+
+ private void writeIndex(List keys) {
+ StringBuilder sb = new StringBuilder();
+ for (Iterator i = keys.iterator(); i.hasNext();) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(i.next());
+ }
+ Preferences.set(INDEX, sb.toString());
+ }
+}
diff --git a/CodenameOne/src/com/codename1/impl/continuity/package-info.java b/CodenameOne/src/com/codename1/impl/continuity/package-info.java
new file mode 100644
index 00000000000..d77c8207f77
--- /dev/null
+++ b/CodenameOne/src/com/codename1/impl/continuity/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+/// The simulated continuity platform behind the simulator, the desktop builds and the unit tests.
+///
+/// Internal. Application code uses `com.codename1.continuity`.
+package com.codename1.impl.continuity;
diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java
index 0a582da09ef..3302311dfac 100644
--- a/CodenameOne/src/com/codename1/router/Navigation.java
+++ b/CodenameOne/src/com/codename1/router/Navigation.java
@@ -105,6 +105,7 @@ public static boolean navigate(String path) {
}
stack.add(new NavigationEntry(path, f));
f.show();
+ stackChanged();
return true;
}
@@ -119,6 +120,7 @@ public static boolean back() {
stack.remove(stack.size() - 1);
NavigationEntry now = stack.get(stack.size() - 1);
now.getForm().showBack();
+ stackChanged();
return true;
}
@@ -162,9 +164,78 @@ public static boolean popTo(NavigationEntry entry) {
stack.remove(stack.size() - 1);
}
entry.getForm().showBack();
+ stackChanged();
return true;
}
+ /// Rebuilds the stack from a list of paths, showing only the last one.
+ ///
+ /// This is how `com.codename1.continuity.Continuity` puts the user back where they were: the
+ /// saved state is a list of paths, and every one of them has to become a stack frame or
+ /// `back()` would land on a screen that was never built. Replaying them with `navigate` would
+ /// work and would also flash every intermediate screen past the user with a transition each,
+ /// so the frames are built silently and only the top one is shown.
+ ///
+ /// Paths that no longer match a route are skipped rather than failing the restore. A rebuilt
+ /// app legitimately drops routes, and refusing to restore anything because one deep frame went
+ /// away would lose the whole session over a screen the user was not on.
+ ///
+ /// Replaces whatever was on the stack. Must be called on the EDT.
+ ///
+ /// #### Parameters
+ ///
+ /// - `paths`: the paths, oldest first
+ ///
+ /// #### Returns
+ ///
+ /// true when at least one frame was rebuilt and shown
+ public static boolean restoreStack(List paths) {
+ RouteDispatcher d = dispatcher;
+ if (d == null || paths == null || paths.isEmpty()) {
+ return false;
+ }
+ List rebuilt = new ArrayList();
+ for (int i = 0; i < paths.size(); i++) {
+ String path = paths.get(i);
+ if (path == null || path.length() == 0) {
+ continue;
+ }
+ Form f;
+ try {
+ f = d.dispatch(path);
+ } catch (Throwable t) {
+ com.codename1.io.Log.e(t);
+ continue;
+ }
+ if (f != null) {
+ rebuilt.add(new NavigationEntry(path, f));
+ }
+ }
+ if (rebuilt.isEmpty()) {
+ return false;
+ }
+ stack.clear();
+ stack.addAll(rebuilt);
+ // show(), not showBack(): the user is arriving, not going back, and showBack would run
+ // the reverse transition into a screen they have not seen yet.
+ rebuilt.get(rebuilt.size() - 1).getForm().show();
+ stackChanged();
+ return true;
+ }
+
+ /// Tells the continuity framework that the stack moved, so it can checkpoint.
+ ///
+ /// A direct call rather than a listener: `Continuity.routeStackChanged()` returns immediately
+ /// unless an application actually enabled continuity, and a listener registry here would be
+ /// public API earned by one internal caller.
+ private static void stackChanged() {
+ try {
+ com.codename1.continuity.Continuity.routeStackChanged();
+ } catch (Throwable t) {
+ com.codename1.io.Log.e(t);
+ }
+ }
+
// ------------------------------------------------------------------------
// Internal: framework-side entry point invoked by Display when the
// platform delivers a deep link through `AppArg`.
diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java
index 59fdef98def..3fc8602f960 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -5873,6 +5873,18 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri
return impl.getDocumentProviderBridge();
}
+ /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the
+ /// user's current activity to their other devices and to reach the platform's synced key/value
+ /// store, or null when unsupported on this port. Internal -- application code uses the
+ /// `com.codename1.continuity` API rather than this bridge directly.
+ ///
+ /// #### Returns
+ ///
+ /// the continuity bridge, or null
+ public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() {
+ return impl.getContinuityBridge();
+ }
+
/// Returns the platform bridge used by the `com.codename1.intents` API to expose the
/// application's capabilities to the system, or null when unsupported on this port. Internal --
/// application code uses the `com.codename1.intents` API rather than this bridge directly.
diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
index 21660be5870..082f97e400f 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -6485,6 +6485,26 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri
return documentProviderBridge;
}
+ private com.codename1.continuity.spi.ContinuityBridge continuityBridge;
+
+ /// Returns the continuity bridge, which on Android exists for one job:
+ /// flushing the state checkpoint when the platform says the process may
+ /// be killed. Neither cross-device capability exists here and both report
+ /// themselves unsupported.
+ ///
+ /// Synchronized for the reason the intent bridge is: two callers arriving
+ /// together would each construct one, and each construction registers a
+ /// lifecycle listener -- so the loser's listener would stay registered and
+ /// the app would checkpoint twice on every save.
+ @Override
+ public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() {
+ if (continuityBridge == null) {
+ continuityBridge =
+ new com.codename1.impl.android.continuity.AndroidContinuityBridge();
+ }
+ return continuityBridge;
+ }
+
private com.codename1.intents.spi.IntentBridge intentBridge;
@Override
diff --git a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java
new file mode 100644
index 00000000000..6a54d9cdd84
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java
@@ -0,0 +1,156 @@
+/*
+ * 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.impl.android.continuity;
+
+import android.os.Bundle;
+
+import com.codename1.continuity.Continuity;
+import com.codename1.continuity.spi.ContinuityBridge;
+import com.codename1.continuity.spi.ContinuityCallback;
+import com.codename1.impl.android.AndroidNativeUtil;
+import com.codename1.impl.android.LifecycleListener;
+import com.codename1.io.Log;
+
+import java.util.Map;
+
+/// Android's half of the continuity framework, which is smaller than the Apple one because the
+/// platform offers less.
+///
+/// #### What Android has
+///
+/// Saving and restoring on this device, which is the part that matters most here: Android reclaims
+/// a backgrounded process routinely, far more readily than iOS does, so an app without this comes
+/// back to its first screen after nothing more than a few minutes in another app. That half is
+/// pure `com.codename1.io.Storage` and needs no bridge at all; what this class adds is a flush at
+/// the one moment the platform tells the app it is about to be killed.
+///
+/// #### What Android does not have
+///
+/// There is no system service that advertises what the user is doing to the other devices they
+/// own, and no key/value store the platform syncs between them. Both are reported unsupported
+/// rather than emulated: an app told "yes" by a bridge that then dropped the state would be worse
+/// off than one told "no", which can fall back to a `com.codename1.continuity.StateRelay` and
+/// reach an iPhone as easily as another Android.
+///
+/// This is the honest shape of the platform difference, and it is why the developer guide's
+/// capability table has a column per platform rather than a single "supported" claim.
+public class AndroidContinuityBridge implements ContinuityBridge {
+
+ /// Registers the flush hook. Called once, when the port builds the bridge.
+ public AndroidContinuityBridge() {
+ try {
+ AndroidNativeUtil.addLifecycleListener(new FlushOnSave());
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public void setCallback(ContinuityCallback callback) {
+ // Nothing to deliver: neither capability below exists on this platform, so the framework's
+ // inbound seam is never reached from here. States still arrive on Android -- through a
+ // StateRelay, which the framework drives itself and which needs no port support.
+ }
+
+ public boolean isContinuationSupported() {
+ return false;
+ }
+
+ public void publishContinuation(String activityType, String title,
+ Map userInfo) {
+ }
+
+ public void clearContinuation() {
+ }
+
+ public boolean isSyncedStoreSupported() {
+ return false;
+ }
+
+ public void syncedStorePut(String key, String value) {
+ }
+
+ public String syncedStoreGet(String key) {
+ return null;
+ }
+
+ public void syncedStoreRemove(String key) {
+ }
+
+ public String[] syncedStoreKeys() {
+ return new String[0];
+ }
+
+ /// Flushes the checkpoint when the platform says the process may be killed.
+ ///
+ /// `onSaveInstanceState` is the right hook and `onStop` is not. Android calls this one *before*
+ /// stopping, while the app is still whole, and it is the last callback guaranteed to run
+ /// before a background process is reclaimed. The app's own `stop()` is not: the generated
+ /// activity blocks Android's main thread waiting for it, so work added there is paid for on
+ /// every ordinary suspend.
+ ///
+ /// The framework has almost always written the state already -- it checkpoints as the user
+ /// navigates rather than at shutdown -- so this exists for the payload edited after the last
+ /// navigation, and is a no-op the rest of the time.
+ private static final class FlushOnSave implements LifecycleListener {
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ }
+
+ @Override
+ public void onResume() {
+ // A relay is the only channel Android has, and nothing reads it on its own. Asking
+ // here is what makes "picked it up on the iPad, opened the phone" work: the poll is a
+ // background request that returns immediately and does nothing at all when no relay is
+ // installed.
+ try {
+ Continuity.pollRelay();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ }
+
+ @Override
+ public void onDestroy() {
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle b) {
+ try {
+ Continuity.checkpoint();
+ } catch (Throwable t) {
+ // Never allowed to escape. This runs on Android's main thread inside a platform
+ // callback, and an exception here takes down the activity as it is being saved --
+ // turning a missed checkpoint into a crash on every suspend.
+ Log.e(t);
+ }
+ }
+
+ @Override
+ public void onLowMemory() {
+ }
+ }
+}
diff --git a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties
index 22514ddf0d9..c79fb62dcd6 100644
--- a/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties
+++ b/Ports/JavaSE/src/META-INF/codenameone/simulator-hooks.properties
@@ -5,7 +5,7 @@
#
# A classpath entry can only carry one copy of this resource, so the
# subsystems are declared as prefixed groups rather than as separate files.
-groups=bluetooth,health,call,vpn
+groups=bluetooth,health,call,vpn,continuity
bluetooth.name=Bluetooth
bluetooth.namespace=bluetooth
@@ -153,3 +153,51 @@ vpn.label6=Make VPN Unsupported
# API-only items below (no label): for tests and scripts.
vpn.item7=com.codename1.impl.javase.VpnSimulatorHooks#makeVpnSupported
+
+continuity.name=Continuity
+continuity.namespace=continuity
+
+# The whole feature in one click: whatever the app is currently advertising
+# is handed straight back to it, as a second device would. Does nothing when
+# no checkpoint has been taken, which is itself the answer to "why is
+# nothing being offered".
+continuity.item1=com.codename1.impl.javase.ContinuitySimulatorHooks#continueHere
+continuity.label1=Continue Here (As Another Device)
+
+continuity.item2=com.codename1.impl.javase.ContinuitySimulatorHooks#checkpointNow
+continuity.label2=Take A Checkpoint Now
+
+# A screen went away in a rebuild and the states already sitting on the
+# user's other devices still name it. The restore has to survive on the
+# frames it can still build.
+continuity.item3=com.codename1.impl.javase.ContinuitySimulatorHooks#continueWithAStaleRoute
+continuity.label3=Continue A Route This Build Dropped
+
+# What an app that does not use @Route produces. The framework shows
+# nothing on its own here, and an app that assumed restore() always shows
+# something finds out now.
+continuity.item4=com.codename1.impl.javase.ContinuitySimulatorHooks#continuePayloadOnly
+continuity.label4=Continue With No Routes (Payload Only)
+
+continuity.item5=com.codename1.impl.javase.ContinuitySimulatorHooks#continueSomethingStale
+continuity.label5=Continue Something From Yesterday
+
+# Carries no values on any platform, so an app that re-reads only the key it
+# assumed changed reads a stale one here.
+continuity.item6=com.codename1.impl.javase.ContinuitySimulatorHooks#changeTheSyncedStore
+continuity.label6=Change The Synced Store Elsewhere
+
+# What every non-Apple platform reports. An app that put a required setting
+# in the synced store and never checked isSupported() loses it here.
+continuity.item7=com.codename1.impl.javase.ContinuitySimulatorHooks#makeTheSyncedStoreUnsupported
+continuity.label7=Make The Synced Store Unsupported
+
+continuity.item8=com.codename1.impl.javase.ContinuitySimulatorHooks#makeContinuationUnsupported
+continuity.label8=Make Continuation Unsupported
+
+continuity.item9=com.codename1.impl.javase.ContinuitySimulatorHooks#makeEverythingSupported
+continuity.label9=Make Everything Supported Again
+
+# API-only items below (no label): for tests and scripts.
+continuity.item10=com.codename1.impl.javase.ContinuitySimulatorHooks#clearStoredState
+continuity.item11=com.codename1.impl.javase.ContinuitySimulatorHooks#clearTheSyncedStore
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java
new file mode 100644
index 00000000000..14cad9821ef
--- /dev/null
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java
@@ -0,0 +1,191 @@
+/*
+ * 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.impl.javase;
+
+import com.codename1.continuity.AppState;
+import com.codename1.continuity.Continuity;
+import com.codename1.continuity.StateCodec;
+import com.codename1.continuity.sync.SyncedStore;
+import com.codename1.impl.continuity.LocalContinuityBridge;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/// Simulator hooks that script state restoration and continuity.
+///
+/// Registered in `META-INF/codenameone/simulator-hooks.properties`. The labelled ones become a
+/// Simulate menu; every one is callable from a test with `CN.execute("continuity:itemN")`.
+///
+/// #### These reproduce traps, not happy paths
+///
+/// Restoring cleanly is what the app does anyway. What is worth a click is the state that arrives
+/// while the user is midway through something, the one that arrives before there is a form to show
+/// it on, the one from a build whose routes have since been renamed, and the synced store changing
+/// underneath a screen that already read it. Each is a real device behaviour an app written
+/// against the cheerful path gets wrong, and each is otherwise reachable only by arranging two
+/// devices.
+public final class ContinuitySimulatorHooks {
+
+ private ContinuitySimulatorHooks() {
+ }
+
+ private static LocalContinuityBridge bridge() {
+ return JavaSEPort.getSimulatedContinuity();
+ }
+
+ /// Hands what this app is currently advertising straight back to it, as though the user had
+ /// picked it up on a second device.
+ ///
+ /// This is the whole feature in one click: publish, then continue. It does nothing when the
+ /// app has not taken a checkpoint yet, which is itself the answer to "why is nothing being
+ /// offered".
+ public static void continueHere() {
+ bridge().simulateArrival();
+ }
+
+ /// Delivers a state that names a route the build no longer has.
+ ///
+ /// An app is rebuilt and a screen goes away, and the states already sitting on the user's
+ /// other devices still name it. The restore has to survive that with the frames it can still
+ /// build rather than losing the session over one screen the user was not even on.
+ public static void continueWithAStaleRoute() {
+ AppState state = new AppState();
+ List routes = new ArrayList();
+ routes.add("/a-route-this-build-no-longer-has");
+ state.setRoutes(routes)
+ .setDeviceId("simulated-device")
+ .setSequence(System.currentTimeMillis())
+ .setTimestamp(System.currentTimeMillis())
+ .setTitle("From a older build");
+ deliver(state);
+ }
+
+ /// Delivers a state whose payload is present but whose route stack is empty, which is what an
+ /// app that does not use `@Route` produces.
+ ///
+ /// The framework restores nothing on its own here: the payload goes to the `StateProvider` and
+ /// showing a form is the app's job. An app that assumed `restore()` always shows something
+ /// finds out here rather than on a customer's phone.
+ public static void continuePayloadOnly() {
+ AppState state = new AppState();
+ Map payload = new HashMap();
+ payload.put("simulated", Boolean.TRUE);
+ state.setPayload(payload)
+ .setDeviceId("simulated-device")
+ .setSequence(System.currentTimeMillis())
+ .setTimestamp(System.currentTimeMillis())
+ .setTitle("Payload only");
+ deliver(state);
+ }
+
+ /// Delivers a state that is a day old.
+ ///
+ /// Exercises `Continuity.setMaxAge(long)` and, more usefully, the listener that has to decide
+ /// whether moving the user somewhere they were yesterday is a courtesy or an ambush.
+ public static void continueSomethingStale() {
+ AppState state = new AppState();
+ state.setRoutes(currentRoutes())
+ .setDeviceId("simulated-device")
+ .setSequence(System.currentTimeMillis())
+ .setTimestamp(System.currentTimeMillis() - 86400000L)
+ .setTitle("From yesterday");
+ deliver(state);
+ }
+
+ /// Reports that the synced store changed on another device, without changing a value.
+ ///
+ /// The notification carries no values on any platform, so an app that assumed it did -- and
+ /// only re-reads the key it thinks changed -- reads a stale one here.
+ public static void changeTheSyncedStore() {
+ bridge().simulateStoreChange();
+ }
+
+ /// Makes the synced store report itself unsupported, which is what every non-Apple platform
+ /// does.
+ ///
+ /// An app that put a required setting in there and never checked `isSupported()` loses it
+ /// here, silently, exactly as it would on Android.
+ public static void makeTheSyncedStoreUnsupported() {
+ JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() {
+ @Override
+ public boolean isSyncedStoreSupported() {
+ return false;
+ }
+ });
+ }
+
+ /// Makes continuation report itself unsupported, which is what every non-Apple platform does.
+ public static void makeContinuationUnsupported() {
+ JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() {
+ @Override
+ public boolean isContinuationSupported() {
+ return false;
+ }
+ });
+ }
+
+ /// Restores the fully capable simulated platform.
+ public static void makeEverythingSupported() {
+ JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge());
+ }
+
+ /// Takes a checkpoint now, so the menu items above have something to hand back.
+ public static void checkpointNow() {
+ Continuity.checkpoint();
+ }
+
+ /// Forgets the stored state, the way a logout does.
+ public static void clearStoredState() {
+ Continuity.clear();
+ }
+
+ /// Empties the simulated synced store.
+ public static void clearTheSyncedStore() {
+ String[] keys = SyncedStore.keys();
+ for (int i = 0; i < keys.length; i++) {
+ SyncedStore.remove(keys[i]);
+ }
+ }
+
+ private static List currentRoutes() {
+ List routes = new ArrayList();
+ List stack =
+ com.codename1.router.Navigation.getStack();
+ for (int i = 0; i < stack.size(); i++) {
+ routes.add(stack.get(i).getPath());
+ }
+ if (routes.isEmpty()) {
+ routes.add("/");
+ }
+ return routes;
+ }
+
+ private static void deliver(AppState state) {
+ // Through the bridge rather than through Continuity directly, so the item exercises the
+ // same inbound path a device uses -- including the activity-type check, which is where a
+ // mismatch between the build's declared type and the framework's would show up.
+ bridge().simulateArrival(Continuity.getActivityType(), StateCodec.toMap(state));
+ }
+}
diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
index 2540198e600..7ad82ad08fb 100644
--- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java
@@ -16590,6 +16590,8 @@ public com.codename1.health.Health getHealth() {
private static com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge;
+ private static com.codename1.impl.continuity.LocalContinuityBridge continuityBridge;
+
private static com.codename1.impl.call.LocalCallBridge callBridge;
private static com.codename1.impl.vpn.LocalVpnBridge vpnBridge;
@@ -16668,6 +16670,55 @@ public static com.codename1.impl.call.LocalCallBridge getSimulatedCalls() {
}
}
+ /// The continuity bridge for the simulator and desktop builds.
+ ///
+ /// A simulated one rather than none, for the reason
+ /// [#getCallBridge()] carries one: an app's continuity work is deciding
+ /// what belongs in the payload, prompting before a jump and rebuilding a
+ /// screen from a route, none of which has anything to do with the
+ /// operating system that carries the state. A port that reported nothing
+ /// would make every bit of it testable only on a pair of phones.
+ ///
+ /// The simulated synced store is backed by `Preferences`, so it survives
+ /// a simulator restart the way the platform store survives a device one.
+ @Override
+ public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() {
+ return getSimulatedContinuity();
+ }
+
+ /// The simulated continuity platform, for the Simulate menu to script.
+ ///
+ /// Static and class-guarded for the reason the call bridge is: it holds
+ /// the advertised activity and the framework's callback, and two threads
+ /// racing this getter would each get their own -- an activity published
+ /// through one would be invisible to the menu item that hands it back.
+ public static com.codename1.impl.continuity.LocalContinuityBridge getSimulatedContinuity() {
+ synchronized (JavaSEPort.class) {
+ if (continuityBridge == null) {
+ continuityBridge = new com.codename1.impl.continuity.LocalContinuityBridge();
+ }
+ return continuityBridge;
+ }
+ }
+
+ /// Replaces the simulated continuity platform, so the Simulate menu can
+ /// swap in one that reports a capability as missing.
+ ///
+ /// The framework's inbound seam is re-installed on the replacement:
+ /// without that the new bridge would have no callback, and every menu
+ /// item that delivers a continuation would silently do nothing.
+ ///
+ /// #### Parameters
+ ///
+ /// - `b`: the replacement, never null
+ public static void setSimulatedContinuity(
+ com.codename1.impl.continuity.LocalContinuityBridge b) {
+ synchronized (JavaSEPort.class) {
+ continuityBridge = b;
+ }
+ com.codename1.continuity.Continuity.refreshBridge();
+ }
+
/// The VPN bridge for the simulator and desktop builds: a simulated
/// configuration store that tunnels nothing.
///
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
index ac569f96dcc..478e3354210 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m
@@ -110,6 +110,13 @@
// where this class is not translated and the header does not exist.
#import "com_codename1_impl_ios_IOSIntentCallbacks.h"
#endif
+#ifdef CN1_USE_CONTINUITY
+// Same reasoning as the intents header above, and the same guard: the continuity branch below
+// calls a translated entry point, and an implicit declaration is a hard error on some slices and
+// a wrong-registers call on the rest. CodenameOne_GLViewController.h undefines
+// CN1_USE_CONTINUITY for watchOS and tvOS, where this class is not translated.
+#import "com_codename1_impl_ios_IOSContinuityCallbacks.h"
+#endif
// A signal handler to handle bad accesses. This will throw NPEs that we can catch
// rather than crashing the app.
@@ -279,6 +286,47 @@ - (BOOL)cn1ContinueUserActivity:(NSUserActivity *)userActivity
#endif
return YES;
}
+#ifdef CN1_USE_CONTINUITY
+ // Continuity is matched BEFORE intents, and the order is load-bearing. The intents block
+ // below ends in a general branch that hands any remaining activity type to Java and returns
+ // Java's answer -- and Intents.dispatchUserActivity correctly answers NO for a type it never
+ // declared. An app using both would therefore have its own continuation asked about by the
+ // wrong framework, told no, and dropped. Matching here first keeps each framework answering
+ // only for the types it published.
+ //
+ // Placed after the browsing-web branch rather than before it for the reason that branch is
+ // first: Universal Link behaviour must be bit-identical whether or not continuity is in play.
+ if (userActivity != nil && [userActivity.activityType hasSuffix:@".continuity"]) {
+ NSString *payload = nil;
+ if (userActivity.userInfo != nil
+ && [NSJSONSerialization isValidJSONObject:userActivity.userInfo]) {
+ NSData *data = [NSJSONSerialization dataWithJSONObject:userActivity.userInfo
+ options:0 error:nil];
+ if (data != nil) {
+ // Autoreleased: the app target is manual-reference-counted and this method
+ // returns without a release, so every continuation would otherwise retain its
+ // serialized payload for the life of the process.
+ payload = [[[NSString alloc] initWithData:data
+ encoding:NSUTF8StringEncoding] autorelease];
+ }
+ }
+ JAVA_OBJECT jtype = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG userActivity.activityType);
+ JAVA_OBJECT jpayload = payload == nil ? JAVA_NULL
+ : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG payload);
+#ifdef NEW_CODENAME_ONE_VM
+ JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload);
+#else
+ JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload);
+#endif
+ if (claimed == JAVA_TRUE) {
+ return YES;
+ }
+ // Not claimed: the suffix matched but the framework did not recognize the type as its
+ // own, which is what a third-party activity whose type happens to end the same way looks
+ // like. Falls through rather than returning NO, so the intents branch below still gets
+ // its chance at it.
+ }
+#endif
#ifdef CN1_USE_INTENTS
// Everything below is compiled only for an app that references
// com.codename1.intents, so a build without it produces exactly the function above.
@@ -523,13 +571,19 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(
if (activityDictionary) {
NSUserActivity *userActivity = [activityDictionary valueForKey:@"UIApplicationLaunchOptionsUserActivityKey"];
if (userActivity != nil) {
-#ifdef CN1_USE_INTENTS
- // A donated activity cold-launching the app arrives here, before the VM
- // callback below has run the application's init/start -- so the framework's
- // dispatcher exists (the generated bootstrap installed it from main) while
- // Display does not, and the handler would run inline with no event thread and
- // no window. Held until initialization instead; browsing-web keeps its existing
- // path, which only stores AppArg and is safe this early.
+#if defined(CN1_USE_INTENTS) || defined(CN1_USE_CONTINUITY)
+ // A donated activity or a continuation cold-launching the app arrives here,
+ // before the VM callback below has run the application's init/start -- so the
+ // framework's dispatcher exists (the generated bootstrap installed it from main)
+ // while Display does not, and the handler would run inline with no event thread
+ // and no window. Held until initialization instead; browsing-web keeps its
+ // existing path, which only stores AppArg and is safe this early.
+ //
+ // Continuity needs the hold for a second reason of its own: its Java callback is
+ // installed by Continuity.enable(), which the application calls from init(). An
+ // activity delivered before that finds no callback at all and is dropped -- so an
+ // app that used continuity WITHOUT intents used to lose exactly the cold launch
+ // the feature exists for.
if (![NSUserActivityTypeBrowsingWeb isEqualToString:userActivity.activityType]) {
cn1PendingLaunchActivity = [userActivity retain];
} else {
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
index e8d27687bea..10a112284a3 100644
--- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
+++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
@@ -222,6 +222,16 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json,
// FileProvider symbols at all.
//#define CN1_USE_DOCUMENTS
+// CN1_USE_CONTINUITY gates state restoration and cross-device continuity: the IOSNative
+// continuity* implementations (NSUserActivity for handing work to a nearby device,
+// NSUbiquitousKeyValueStore for the synced store) plus the continuity branch in
+// CodenameOne_GLAppDelegate. IPhoneBuilder uncomments this only when the classpath scanner saw
+// com.codename1.continuity.*, so an app that restores nothing links neither.
+//
+// Note what this define does NOT gate: saving and restoring state on this device is pure Java
+// over com.codename1.io.Storage and works in every build, define or no define.
+//#define CN1_USE_CONTINUITY
+
// CN1_APP_INTENTS_DECLARED is the narrower question: did the build actually generate App Intent
// declarations? CN1_USE_INTENTS only says the app references the package, and an app can use
// indexing and donation while switching declarations off with ios.intents.appIntents=false.
@@ -232,9 +242,13 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json,
//#define CN1_APP_INTENTS_DECLARED
// Core Spotlight and App Intents are unavailable on watchOS / tvOS; undo the defines there.
+// Continuity goes with them: NSUserActivity handoff has no watchOS or tvOS counterpart, and
+// NSUbiquitousKeyValueStore is unavailable on both. The Java half is unaffected -- a watch app
+// still saves and restores its own state, which is the half that needs no native support.
#if TARGET_OS_WATCH || TARGET_OS_TV
#undef CN1_USE_INTENTS
#undef CN1_APP_INTENTS_DECLARED
+#undef CN1_USE_CONTINUITY
#endif
// CN1_USE_WATCHCONNECTIVITY gates the phone-to-watch link (CN1WatchConnectivity.{h,m} + the
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 17165743dec..e75757bb71a 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -20016,6 +20016,303 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole
return com_codename1_impl_ios_IOSNative_intentsIndexingSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject);
}
+
+// --- State restoration and continuity (com.codename1.continuity) -------------
+//
+// Two unrelated Apple mechanisms, gated together on CN1_USE_CONTINUITY but answered separately
+// to Java, because they cost different things. NSUserActivity with eligibleForHandoff carries
+// what the user is doing to a device they are holding and needs no entitlement at all; the
+// NSUbiquitousKeyValueStore below carries a few durable values to every device on the account
+// and needs one that has to be granted on the App ID. An app wanting only the first must not be
+// made to arrange the second, which is why com.codename1.continuity.sync is a separate package
+// and why the store reports its own availability rather than assuming it.
+//
+// What is NOT here: saving and restoring state on this device. That is pure Java over
+// com.codename1.io.Storage and works in every build, with or without this define.
+
+#ifdef CN1_USE_CONTINUITY
+
+/// The advertised activity, or nil. A single slot rather than the bounded ring the intent
+/// donations use: a donation is a historical fact the system may keep offering, while this is
+/// "what the user is doing right now" and there is only ever one of those. Publishing again
+/// replaces it.
+static NSUserActivity *cn1ContinuityActivity = nil;
+
+/// Retained so the observer can be reasoned about, though nothing ever removes it: the store's
+/// external-change notification is wanted for the entire life of the process.
+static id cn1ContinuityStoreObserver = nil;
+
+// The translated entry point the store observer below calls. Without this it is an implicit
+// declaration, which C99 and every clang that enforces it reject outright -- and where a
+// toolchain still accepts one, the invented prototype passes the thread state through whatever
+// registers the default promotions choose. Same reasoning, and the same fix, as the
+// IOSWearableCallbacks declarations further down this file.
+extern JAVA_VOID com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__(
+ CODENAME_ONE_THREAD_STATE);
+
+static NSDictionary *cn1ContinuityParseJson(NSString *json) {
+ if (json == nil) {
+ return nil;
+ }
+ NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding];
+ if (data == nil) {
+ return nil;
+ }
+ id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ return [parsed isKindOfClass:[NSDictionary class]] ? (NSDictionary *)parsed : nil;
+}
+
+/// Reduces a parsed JSON value to what a property list can hold, recursively.
+///
+/// The intent donation path beside this one flattens to strings and numbers, which is all a
+/// donation carries. A continuity payload is nested by construction -- an array of route paths
+/// and a map of the application's own values -- so flattening it would deliver an activity with
+/// the routes silently missing, which looks exactly like the feature not working.
+///
+/// Anything with no property-list representation, NSNull included, is dropped rather than
+/// substituted: the Java side validated the payload where the application produced it, so the
+/// only values that can reach here are ones JSON introduced on its own.
+static id cn1ContinuitySanitize(id value) {
+ if ([value isKindOfClass:[NSString class]] || [value isKindOfClass:[NSNumber class]]) {
+ return value;
+ }
+ if ([value isKindOfClass:[NSArray class]]) {
+ NSMutableArray *out = [NSMutableArray array];
+ for (id item in (NSArray *)value) {
+ id safe = cn1ContinuitySanitize(item);
+ if (safe != nil) {
+ [out addObject:safe];
+ }
+ }
+ return out;
+ }
+ if ([value isKindOfClass:[NSDictionary class]]) {
+ NSMutableDictionary *out = [NSMutableDictionary dictionary];
+ NSDictionary *dict = (NSDictionary *)value;
+ for (id key in dict) {
+ if (![key isKindOfClass:[NSString class]]) {
+ continue;
+ }
+ id safe = cn1ContinuitySanitize([dict objectForKey:key]);
+ if (safe != nil) {
+ [out setObject:safe forKey:key];
+ }
+ }
+ return out;
+ }
+ return nil;
+}
+
+/// The synced store, or nil when this build did not earn one.
+///
+/// Resolved once and cached, because the answer cannot change while the process runs: it is a
+/// property of how the app was signed.
+///
+/// Three guards rather than one, and deliberately so. The entitlement is missing in the ordinary
+/// case that an app references com.codename1.continuity.sync and the App ID never had iCloud
+/// enabled, and what that produces has not been the same across releases of iOS -- a nil store, a
+/// store whose synchronize answers NO, and a raised exception have all been reported. Guessing
+/// which one this OS does would leave the app writing values into nothing on the others, and the
+/// symptom of that is a setting that silently fails to follow the user.
+static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) {
+ static NSUbiquitousKeyValueStore *store = nil;
+ static BOOL resolved = NO;
+ if (resolved) {
+ return store;
+ }
+ resolved = YES;
+ @try {
+ NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore];
+ if (s != nil && [s synchronize]) {
+ store = [s retain];
+ cn1ContinuityStoreObserver = [[[NSNotificationCenter defaultCenter]
+ addObserverForName:NSUbiquitousKeyValueStoreDidChangeExternallyNotification
+ object:s
+ queue:nil
+ usingBlock:^(NSNotification *note) {
+ com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__(
+ CN1_THREAD_GET_STATE_PASS_SINGLE_ARG);
+ }] retain];
+ }
+ } @catch (NSException *e) {
+ store = nil;
+ }
+ return store;
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ return JAVA_TRUE;
+}
+
+void com_codename1_impl_ios_IOSNative_continuityPublish___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityType, JAVA_OBJECT title, JAVA_OBJECT userInfoJson) {
+ if (activityType == JAVA_NULL) {
+ return;
+ }
+ POOL_BEGIN();
+ NSString *type = toNSString(CN1_THREAD_STATE_PASS_ARG activityType);
+ NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:type];
+ // The one property that makes this a continuation rather than a donation. Without it the
+ // activity is only a Siri/Spotlight hint and no other device is ever offered it -- which is
+ // exactly the shape of the intents path beside this one, and the reason the two do not share
+ // a code path despite building the same class.
+ activity.eligibleForHandoff = YES;
+ if (title != JAVA_NULL) {
+ NSString *label = toNSString(CN1_THREAD_STATE_PASS_ARG title);
+ if ([label length] > 0) {
+ activity.title = label;
+ }
+ }
+ if (userInfoJson != JAVA_NULL) {
+ id safe = cn1ContinuitySanitize(cn1ContinuityParseJson(
+ toNSString(CN1_THREAD_STATE_PASS_ARG userInfoJson)));
+ if ([safe isKindOfClass:[NSDictionary class]]) {
+ activity.userInfo = (NSDictionary *)safe;
+ }
+ }
+ [activity becomeCurrent];
+ // Ownership of the alloc's reference moves into the slot; the previous occupant is
+ // invalidated so the system stops offering a state the app has moved on from, and then
+ // released, since this slot held the only reference to it in a manual-reference-counted
+ // target.
+ NSUserActivity *previous = cn1ContinuityActivity;
+ cn1ContinuityActivity = activity;
+ if (previous != nil) {
+ [previous invalidate];
+ [previous release];
+ }
+ POOL_END();
+}
+
+void com_codename1_impl_ios_IOSNative_continuityClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ if (cn1ContinuityActivity == nil) {
+ return;
+ }
+ POOL_BEGIN();
+ NSUserActivity *activity = cn1ContinuityActivity;
+ // Cleared before the messages, so a second call cannot resign and release the same activity
+ // twice -- which in a manual-reference-counted target is an over-release, not a no-op.
+ cn1ContinuityActivity = nil;
+ [activity resignCurrent];
+ [activity invalidate];
+ [activity release];
+ POOL_END();
+}
+
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ return cn1ContinuityStore() != nil ? JAVA_TRUE : JAVA_FALSE;
+}
+
+void com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key, JAVA_OBJECT value) {
+ NSUbiquitousKeyValueStore *store = cn1ContinuityStore();
+ if (store == nil || key == JAVA_NULL || value == JAVA_NULL) {
+ return;
+ }
+ POOL_BEGIN();
+ [store setString:toNSString(CN1_THREAD_STATE_PASS_ARG value)
+ forKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)];
+ // Asked for rather than waited on. The system syncs on its own schedule and this only moves
+ // it along; the return value says whether the store is usable at all, which cn1ContinuityStore
+ // already established.
+ [store synchronize];
+ POOL_END();
+}
+
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) {
+ NSUbiquitousKeyValueStore *store = cn1ContinuityStore();
+ if (store == nil || key == JAVA_NULL) {
+ return JAVA_NULL;
+ }
+ JAVA_OBJECT result = JAVA_NULL;
+ POOL_BEGIN();
+ NSString *value = [store stringForKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)];
+ if (value != nil) {
+ result = fromNSString(CN1_THREAD_STATE_PASS_ARG value);
+ }
+ POOL_END();
+ return result;
+}
+
+void com_codename1_impl_ios_IOSNative_continuitySyncedStoreRemove___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) {
+ NSUbiquitousKeyValueStore *store = cn1ContinuityStore();
+ if (store == nil || key == JAVA_NULL) {
+ return;
+ }
+ POOL_BEGIN();
+ [store removeObjectForKey:toNSString(CN1_THREAD_STATE_PASS_ARG key)];
+ [store synchronize];
+ POOL_END();
+}
+
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ NSUbiquitousKeyValueStore *store = cn1ContinuityStore();
+ if (store == nil) {
+ return JAVA_NULL;
+ }
+ JAVA_OBJECT result = JAVA_NULL;
+ POOL_BEGIN();
+ NSArray *keys = [[store dictionaryRepresentation] allKeys];
+ NSMutableArray *strings = [NSMutableArray array];
+ for (id key in keys) {
+ if ([key isKindOfClass:[NSString class]]) {
+ [strings addObject:key];
+ }
+ }
+ // Wrapped in an object because the Java side parses it with JSONParser, whose entry point
+ // reads a document whose root is an object. A bare array would parse to nothing.
+ NSDictionary *doc = [NSDictionary dictionaryWithObject:strings forKey:@"keys"];
+ NSData *data = [NSJSONSerialization isValidJSONObject:doc]
+ ? [NSJSONSerialization dataWithJSONObject:doc options:0 error:nil] : nil;
+ if (data != nil) {
+ NSString *json = [[[NSString alloc] initWithData:data
+ encoding:NSUTF8StringEncoding] autorelease];
+ result = fromNSString(CN1_THREAD_STATE_PASS_ARG json);
+ }
+ POOL_END();
+ return result;
+}
+
+#else // CN1_USE_CONTINUITY
+
+// Continuity not enabled: no NSUserActivity or iCloud references, everything unsupported. The
+// on-device half of the framework is unaffected, being pure Java.
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ return JAVA_FALSE;
+}
+void com_codename1_impl_ios_IOSNative_continuityPublish___java_lang_String_java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT activityType, JAVA_OBJECT title, JAVA_OBJECT userInfoJson) {
+}
+void com_codename1_impl_ios_IOSNative_continuityClear__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+}
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ return JAVA_FALSE;
+}
+void com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key, JAVA_OBJECT value) {
+}
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) {
+ return JAVA_NULL;
+}
+void com_codename1_impl_ios_IOSNative_continuitySyncedStoreRemove___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT key) {
+}
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me) {
+ return JAVA_NULL;
+}
+#endif // CN1_USE_CONTINUITY
+
+// New-VM (return-type-encoded) manglings for the value-returning continuity natives. Defined
+// after the implementations/stubs above so each call targets an already-declared function. The
+// void continuity* methods need no _R_ wrapper. Always defined regardless of CN1_USE_CONTINUITY.
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return com_codename1_impl_ios_IOSNative_continuitySupported__(CN1_THREAD_STATE_PASS_ARG instanceObject);
+}
+JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return com_codename1_impl_ios_IOSNative_continuitySyncedStoreSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject);
+}
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String_R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT key) {
+ return com_codename1_impl_ios_IOSNative_continuitySyncedStoreGet___java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, key);
+}
+JAVA_OBJECT com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys___R_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) {
+ return com_codename1_impl_ios_IOSNative_continuitySyncedStoreKeys__(CN1_THREAD_STATE_PASS_ARG instanceObject);
+}
+
// --- Phone-to-watch link (com.codename1.wearable / WatchConnectivity) --------
//
// Compiled into BOTH the phone target and the watch target: WCSession is symmetric, so the two
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java
new file mode 100644
index 00000000000..7f1bcb83a63
--- /dev/null
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java
@@ -0,0 +1,181 @@
+/*
+ * 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.impl.ios;
+
+import com.codename1.continuity.spi.ContinuityBridge;
+import com.codename1.continuity.spi.ContinuityCallback;
+import com.codename1.io.JSONParser;
+import com.codename1.io.JSONWriter;
+import com.codename1.io.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/// Apple's half of the continuity framework: `NSUserActivity` for handing work to a device the
+/// user is holding, and `NSUbiquitousKeyValueStore` for the handful of values that should follow
+/// them everywhere.
+///
+/// #### The two halves are independent
+///
+/// Advertising an activity costs nothing but a declared activity type in the app's `Info.plist`.
+/// The synced store costs an entitlement, which has to be granted on the App ID before the app
+/// will sign at all. `isSyncedStoreSupported()` therefore asks the native side rather than
+/// returning a constant: a build that did not earn the entitlement has no store, and answering
+/// "yes" would have the app writing values that silently go nowhere.
+///
+/// #### Everything crosses as JSON
+///
+/// Matching the intent natives beside these. The payload has to become an `NSDictionary` the
+/// system will accept in an activity's `userInfo`, and doing that conversion once, in C, against
+/// a parsed JSON document is simpler than a per-type native call and is the same shape the rest of
+/// this port already uses.
+class IOSContinuityBridge implements ContinuityBridge {
+ private final IOSNative nativeInterface;
+ private final boolean supported;
+
+ IOSContinuityBridge(IOSNative n) {
+ nativeInterface = n;
+ boolean s;
+ try {
+ s = n.continuitySupported();
+ } catch (Throwable t) {
+ Log.e(t);
+ s = false;
+ }
+ supported = s;
+ }
+
+ public void setCallback(ContinuityCallback callback) {
+ IOSContinuityCallbacks.setCallback(callback);
+ }
+
+ public boolean isContinuationSupported() {
+ return supported;
+ }
+
+ public void publishContinuation(String activityType, String title,
+ Map userInfo) {
+ if (!supported) {
+ return;
+ }
+ try {
+ nativeInterface.continuityPublish(activityType, title,
+ userInfo == null ? null : JSONWriter.toJson(userInfo));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public void clearContinuation() {
+ if (!supported) {
+ return;
+ }
+ try {
+ nativeInterface.continuityClear();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public boolean isSyncedStoreSupported() {
+ if (!supported) {
+ return false;
+ }
+ try {
+ return nativeInterface.continuitySyncedStoreSupported();
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ public void syncedStorePut(String key, String value) {
+ if (!isSyncedStoreSupported()) {
+ return;
+ }
+ try {
+ nativeInterface.continuitySyncedStorePut(key, value);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public String syncedStoreGet(String key) {
+ if (!isSyncedStoreSupported()) {
+ return null;
+ }
+ try {
+ return nativeInterface.continuitySyncedStoreGet(key);
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ public void syncedStoreRemove(String key) {
+ if (!isSyncedStoreSupported()) {
+ return;
+ }
+ try {
+ nativeInterface.continuitySyncedStoreRemove(key);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ public String[] syncedStoreKeys() {
+ if (!isSyncedStoreSupported()) {
+ return new String[0];
+ }
+ // The native call and the parse are what can fail, so they are what the handler covers.
+ // Everything below it is deliberately outside: the compiler inserts checked casts for the
+ // generic element type and for toArray's component type, and a failed cast does not throw
+ // on this virtual machine -- so a handler wrapped around one is a handler that cannot run
+ // here. See the ClassCastException note in CLAUDE.md.
+ Map parsed;
+ try {
+ String json = nativeInterface.continuitySyncedStoreKeys();
+ if (json == null || json.length() == 0) {
+ return new String[0];
+ }
+ parsed = JSONParser.parseJSON(json);
+ } catch (Throwable t) {
+ Log.e(t);
+ return new String[0];
+ }
+ Object keys = parsed == null ? null : parsed.get("keys");
+ if (!(keys instanceof List)) {
+ return new String[0];
+ }
+ List> read = (List>) keys;
+ List out = new ArrayList();
+ for (int i = 0; i < read.size(); i++) {
+ Object key = read.get(i);
+ if (key instanceof String) {
+ out.add((String) key);
+ }
+ }
+ return out.toArray(new String[out.size()]);
+ }
+}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java
new file mode 100644
index 00000000000..24ce5a0b774
--- /dev/null
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java
@@ -0,0 +1,145 @@
+/*
+ * 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.impl.ios;
+
+import com.codename1.continuity.spi.ContinuityCallback;
+import com.codename1.io.JSONParser;
+import com.codename1.io.Log;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/// Static callback surface the native continuity glue calls into.
+///
+/// #### Why the static initializer calls everything once
+///
+/// ParparVM's dead-code eliminator decides a Java method is reachable by scanning the `.m` sources
+/// for its mangled symbol and by following Java call graphs. These methods have no Java caller, and
+/// the failure mode when they are stripped is not a link error -- they translate to empty stubs and
+/// the native dispatch silently does nothing, so the build is green and continuations never arrive.
+/// The guarded self-call in the static initializer is what keeps them alive.
+///
+/// The call must be unconditional. Wrapping it in an `if` the optimizer can prove false folds the
+/// whole thing away and reintroduces the bug.
+final class IOSContinuityCallbacks {
+ private static ContinuityCallback callback;
+ private static boolean dceGuard;
+
+ /// A continuation that arrived before the framework was enabled, and the type it arrived
+ /// under. Only ever one: a cold launch delivers a single activity, and a second arrival means
+ /// the app is running and the callback is installed.
+ private static String pendingType;
+ private static String pendingJson;
+
+ static {
+ // Keep the native callback targets reachable for the iOS VM optimizer.
+ dceGuard = true;
+ nativeContinuation(null, null);
+ nativeSyncedStoreChanged();
+ dceGuard = false;
+ }
+
+ private IOSContinuityCallbacks() {
+ }
+
+ static void setCallback(ContinuityCallback c) {
+ callback = c;
+ String type = pendingType;
+ String json = pendingJson;
+ pendingType = null;
+ pendingJson = null;
+ if (c != null && type != null) {
+ // A continuation that cold-launched the app can reach this class before the
+ // application's init() has called Continuity.enable(), which is what installs the
+ // callback -- the scene delegate hands it over from willConnectToSession, which runs
+ // first. Delivered now instead of dropped, which is what the whole feature is for.
+ try {
+ c.continuationReceived(type, parse(json));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ }
+
+ /// An `NSUserActivity` of this app's continuity type arrived.
+ ///
+ /// #### Returns
+ ///
+ /// true when the framework claimed it, so the delegate can answer the system honestly rather
+ /// than swallowing an activity this app never published
+ public static boolean nativeContinuation(String activityType, String userInfoJson) {
+ if (dceGuard) {
+ return false;
+ }
+ ContinuityCallback c = callback;
+ if (c == null) {
+ // The framework has not been enabled yet. That is the ordinary cold-launch ordering
+ // rather than a mistake, so the activity is held for setCallback to deliver instead
+ // of being dropped.
+ //
+ // Claimed all the same. The delegate's answer decides whether the activity falls
+ // through to the intents branch beside it, and one this app is about to act on must
+ // not: an app using both frameworks would otherwise have its own continuation offered
+ // to the wrong one, which would correctly decline it, and the launch would land on the
+ // home screen.
+ pendingType = activityType;
+ pendingJson = userInfoJson;
+ return true;
+ }
+ try {
+ return c.continuationReceived(activityType, parse(userInfoJson));
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// The synced store changed on another of the user's devices.
+ public static void nativeSyncedStoreChanged() {
+ if (dceGuard) {
+ return;
+ }
+ ContinuityCallback c = callback;
+ if (c == null) {
+ return;
+ }
+ try {
+ c.syncedStoreChanged();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private static Map parse(String json) {
+ if (json == null || json.length() == 0) {
+ return new HashMap();
+ }
+ try {
+ Map parsed = JSONParser.parseJSON(json);
+ return parsed == null ? new HashMap() : parsed;
+ } catch (Throwable t) {
+ Log.e(t);
+ return new HashMap();
+ }
+ }
+}
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index c8cfe878a4c..eb8043156c4 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -493,6 +493,21 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri
return documentProviderBridge;
}
+ private IOSContinuityBridge continuityBridge;
+
+ @Override
+ public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() {
+ // Only meaningful in builds that linked the continuity natives (CN1_USE_CONTINUITY,
+ // flipped by the builder when the app references com.codename1.continuity). Always
+ // returned: the bridge asks the native side once and answers honestly, and the native
+ // stubs to unsupported when the define is off. Returning null instead would also disable
+ // the on-device half of the framework, which needs no native support at all.
+ if (continuityBridge == null) {
+ continuityBridge = new IOSContinuityBridge(nativeInstance);
+ }
+ return continuityBridge;
+ }
+
private IOSIntentBridge intentBridge;
@Override
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
index bf685409486..3cdfea3fa51 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -1372,6 +1372,45 @@ native void surfacesMirrorToWatch(String kindId, String timelineJson,
*/
native void intentsCompleteInvocation(String token, String resultJson);
+ // --- State restoration and continuity -----------------------------------
+ // Backs com.codename1.continuity. Two unrelated Apple mechanisms sit behind these and are
+ // answered separately: NSUserActivity carries the current activity to a device that is
+ // physically nearby, and NSUbiquitousKeyValueStore carries a few durable values to every
+ // device on the account whether they are nearby or not. The first needs no entitlement and
+ // the second needs one, which is why com.codename1.continuity.sync is a package of its own.
+ // Payloads cross as JSON strings, matching the intents natives above.
+
+ /** True when this build linked the continuation natives at all. */
+ native boolean continuitySupported();
+
+ /**
+ * Advertises the current activity to the user's nearby devices, replacing whatever was
+ * advertised before. The JSON is the state; the title is what the receiving device shows.
+ */
+ native void continuityPublish(String activityType, String title, String userInfoJson);
+
+ /** Withdraws the advertised activity. */
+ native void continuityClear();
+
+ /** True when this build linked the synced store and the entitlement granted one. */
+ native boolean continuitySyncedStoreSupported();
+
+ /** Writes a value to the synced store. */
+ native void continuitySyncedStorePut(String key, String value);
+
+ /** Reads a value from the synced store, or null when the key is absent. */
+ native String continuitySyncedStoreGet(String key);
+
+ /** Removes a key from the synced store. */
+ native void continuitySyncedStoreRemove(String key);
+
+ /**
+ * Every key in the synced store, as {@code {"keys":["a","b"]}}. A JSON document rather than
+ * a {@code String[]} because every other native here exchanges JSON, and because a store key
+ * is an application-chosen string that no separator character is safe against.
+ */
+ native String continuitySyncedStoreKeys();
+
// --- Phone-to-watch link (WatchConnectivity) ----------------------------
// Backs com.codename1.wearable. The same natives serve both halves of a pair: WCSession is
// symmetric, so the phone app and the watch app run identical code. Payloads cross as opaque
diff --git a/Samples/samples/ContinuitySample/ContinuitySample.java b/Samples/samples/ContinuitySample/ContinuitySample.java
new file mode 100644
index 00000000000..fa674c1f4a3
--- /dev/null
+++ b/Samples/samples/ContinuitySample/ContinuitySample.java
@@ -0,0 +1,228 @@
+/*
+ * 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.samples;
+
+import com.codename1.continuity.AppState;
+import com.codename1.continuity.Continuity;
+import com.codename1.continuity.ContinuityListener;
+import com.codename1.continuity.StateProvider;
+import com.codename1.continuity.sync.SyncedStore;
+import com.codename1.continuity.sync.SyncedStoreListener;
+import com.codename1.ui.Button;
+import com.codename1.ui.Dialog;
+import com.codename1.ui.Display;
+import com.codename1.ui.Form;
+import com.codename1.ui.Label;
+import com.codename1.ui.TextArea;
+import com.codename1.ui.Toolbar;
+import com.codename1.ui.events.ActionEvent;
+import com.codename1.ui.events.ActionListener;
+import com.codename1.ui.layouts.BoxLayout;
+import com.codename1.ui.plaf.UIManager;
+import com.codename1.ui.util.Resources;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Demonstrates {@code com.codename1.continuity}: keeping the user's work across a process death,
+ * and handing it to another device they own.
+ *
+ *
Deliberately without {@code @Route}. An app whose screens are declared with routes gets its
+ * navigation stack restored for free and shows nothing of the mechanism, which makes a poor
+ * demonstration -- so this one carries its whole state in the payload, which is also the harder
+ * of the two cases and the one that needs the code below.
+ *
+ *
To see it work in the simulator: type into the field, then use
+ * {@code Simulate -> Continuity -> Continue Here (As Another Device)}. On two Apple devices signed
+ * in to the same account, type on one and launch the app on the other.
+ */
+public class ContinuitySample {
+
+ private Form current;
+ private Resources theme;
+
+ /** The whole of this app's state. Read by the provider, written by the field. */
+ private String draft = "";
+
+ /** Where the field was scrolled to, which is the sort of thing a route cannot carry. */
+ private int caret;
+
+ private TextArea field;
+ private Label status;
+
+ public void init(Object context) {
+ theme = UIManager.initFirstTheme("/theme");
+ Toolbar.setGlobalToolbar(true);
+
+ // Installing a provider is what turns the framework on. Nothing before this line has any
+ // effect, which is what keeps an app that does not use continuity behaving as it always
+ // did.
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ Map state = new HashMap();
+ state.put("draft", draft);
+ state.put("caret", Integer.valueOf(caret));
+ return state;
+ }
+
+ public void restoreState(Map state) {
+ Object savedDraft = state.get("draft");
+ if (savedDraft instanceof String) {
+ draft = (String) savedDraft;
+ }
+ Object savedCaret = state.get("caret");
+ // instanceof rather than a cast: a state that crossed from another device came
+ // through JSON, where every number is a Double, and a failed cast does not throw
+ // on the iOS virtual machine.
+ if (savedCaret instanceof Number) {
+ caret = ((Number) savedCaret).intValue();
+ }
+ }
+ });
+
+ // Ask before moving the user. Jumping them somewhere without warning is the wrong default
+ // for anything they might be midway through, and holding the state is a one-liner.
+ Continuity.setAutoRestore(false);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(final AppState state) {
+ String label = state.getTitle() == null ? "your other device" : state.getTitle();
+ if (Dialog.show("Continue?", "Pick up \"" + label + "\"?", "Continue", "Stay")) {
+ Continuity.restore(state);
+ showDraftForm();
+ }
+ // Consumed either way: the decision has been made here, so no other listener is
+ // asked and nothing is restored behind this one's back.
+ return false;
+ }
+ });
+
+ SyncedStore.addChangeListener(new SyncedStoreListener() {
+ public void storeChanged() {
+ refreshStatus();
+ }
+ });
+ }
+
+ public void start() {
+ if (current != null) {
+ current.show();
+ return;
+ }
+ // "Restore, or else begin". This app records no routes, so restore() hands the payload to
+ // the provider and answers false -- the screen is still this app's to show.
+ Continuity.restore();
+ showDraftForm();
+ }
+
+ public void stop() {
+ current = Display.getInstance().getCurrent();
+ if (current instanceof Dialog) {
+ ((Dialog) current).dispose();
+ current = Display.getInstance().getCurrent();
+ }
+ }
+
+ public void destroy() {
+ }
+
+ private void showDraftForm() {
+ Form form = new Form("Continuity", BoxLayout.y());
+
+ field = new TextArea(draft, 5, 40);
+ field.addActionListener(new ActionListener() {
+ public void actionPerformed(ActionEvent evt) {
+ capture();
+ }
+ });
+ form.add(new Label("Type something, then continue it elsewhere:"));
+ form.add(field);
+
+ status = new Label("");
+ form.add(status);
+
+ Button checkpoint = new Button("Save a checkpoint now");
+ checkpoint.addActionListener(new ActionListener() {
+ public void actionPerformed(ActionEvent evt) {
+ capture();
+ Dialog.show("Saved", "Advertised as \"" + Continuity.getTitle() + "\".", "OK", null);
+ }
+ });
+ form.add(checkpoint);
+
+ Button remember = new Button("Remember this device's choice");
+ remember.addActionListener(new ActionListener() {
+ public void actionPerformed(ActionEvent evt) {
+ // A write that reports whether it happened, because the store does not exist on
+ // most platforms and is finite where it does.
+ if (!SyncedStore.put("lastEditor", Display.getInstance().getPlatformName())) {
+ Dialog.show("No synced store", "This platform has none, so the choice stays "
+ + "on this device.", "OK", null);
+ }
+ refreshStatus();
+ }
+ });
+ form.add(remember);
+
+ Button forget = new Button("Log out (forget everything)");
+ forget.addActionListener(new ActionListener() {
+ public void actionPerformed(ActionEvent evt) {
+ draft = "";
+ caret = 0;
+ // The advertised activity outlives this screen, so an account's work would stay
+ // on offer to the devices around it without this.
+ Continuity.clear();
+ field.setText("");
+ refreshStatus();
+ }
+ });
+ form.add(forget);
+
+ refreshStatus();
+ form.show();
+ }
+
+ /** Reads the screen into the fields the provider reports, then checkpoints. */
+ private void capture() {
+ draft = field.getText();
+ caret = field.getCursorPosition();
+ // A title names the WORK, not the screen: it is what another device shows the user before
+ // they accept.
+ Continuity.setTitle(draft.length() == 0 ? "An empty draft"
+ : "Draft: " + draft.substring(0, Math.min(24, draft.length())));
+ Continuity.checkpoint();
+ refreshStatus();
+ }
+
+ private void refreshStatus() {
+ if (status == null) {
+ return;
+ }
+ status.setText("continuation: " + (Continuity.isContinuationSupported() ? "yes" : "no")
+ + " | synced store: " + (SyncedStore.isSupported() ? "yes" : "no")
+ + " | last editor: " + SyncedStore.get("lastEditor", "none"));
+ if (status.getComponentForm() != null) {
+ status.getComponentForm().revalidate();
+ }
+ }
+}
diff --git a/Samples/samples/ContinuitySample/codenameone_settings.properties b/Samples/samples/ContinuitySample/codenameone_settings.properties
new file mode 100644
index 00000000000..d5bb6fec2da
--- /dev/null
+++ b/Samples/samples/ContinuitySample/codenameone_settings.properties
@@ -0,0 +1,9 @@
+#Continuity sample build hints
+# Declares that this project hands the user's work between their devices. The build detects the
+# reference to com.codename1.continuity on its own; the hint is what lets the Certificate Wizard
+# and the signing preflight know whether an iCloud capability will be wanted.
+codename1.arg.ios.continuity.enabled=true
+# This sample touches com.codename1.continuity.sync, so the build asks for the iCloud key-value
+# store entitlement -- which the App ID has to grant. Uncomment to drop it and leave SyncedStore
+# reporting itself unsupported; handing work to a nearby device is unaffected either way.
+#codename1.arg.ios.continuity.sync=false
diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java
new file mode 100644
index 00000000000..0065ef289fe
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java
@@ -0,0 +1,175 @@
+/*
+ * 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.codenameone.developerguide.continuity;
+
+import com.codename1.continuity.AppState;
+import com.codename1.continuity.Continuity;
+import com.codename1.continuity.ContinuityListener;
+import com.codename1.continuity.RestStateRelay;
+import com.codename1.continuity.StateProvider;
+import com.codename1.continuity.sync.SyncedStore;
+import com.codename1.continuity.sync.SyncedStoreListener;
+import com.codename1.router.Navigation;
+import com.codename1.ui.Dialog;
+import com.codename1.ui.TextArea;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Snippets that accompany the State Restoration and Continuity guide chapter. Each block between
+ * the tag markers is included verbatim into the AsciiDoc.
+ */
+public class ContinuitySnippets {
+
+ /** Stands in for the screen the application is showing. */
+ private TextArea draftField = new TextArea();
+
+ /** Stands in for the application's own session object. */
+ private Session session = new Session();
+
+ /** A state the application held back rather than acting on immediately. */
+ private AppState held;
+
+ static class Session {
+ String getAccessToken() {
+ return "a-token";
+ }
+ }
+
+ // tag::provider[]
+ public void init(Object context) {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ Map state = new HashMap();
+ state.put("draft", draftField.getText());
+ return state;
+ }
+
+ public void restoreState(Map state) {
+ draftField.setText((String) state.get("draft"));
+ }
+ });
+ }
+ // end::provider[]
+
+ // tag::start[]
+ public void start() {
+ if (!Continuity.restore()) {
+ Navigation.navigate("/home");
+ }
+ }
+ // end::start[]
+
+ // tag::checkpoint[]
+ public void onDraftSaved() {
+ Continuity.setTitle("Draft to Dana");
+ Continuity.checkpoint();
+ }
+ // end::checkpoint[]
+
+ // tag::askFirst[]
+ public void askBeforeMovingTheUser() {
+ Continuity.setAutoRestore(false);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ held = state;
+ if (Dialog.show("Continue?", "Pick up \"" + state.getTitle()
+ + "\" from your other device?", "Continue", "Stay here")) {
+ Continuity.restore(held);
+ }
+ // Consumed either way: the decision has been made here.
+ return false;
+ }
+ });
+ }
+ // end::askFirst[]
+
+ // tag::relay[]
+ public void useMyOwnEndpoint() {
+ Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") {
+ @Override
+ protected String getToken() {
+ return session.getAccessToken();
+ }
+ });
+ }
+ // end::relay[]
+
+ // tag::pollOnResume[]
+ public void onAppResumed() {
+ Continuity.pollRelay();
+ }
+ // end::pollOnResume[]
+
+ // tag::syncedStore[]
+ public String readSortOrder() {
+ return SyncedStore.get("sortOrder", "byName");
+ }
+
+ public void writeSortOrder(String order) {
+ if (!SyncedStore.put("sortOrder", order)) {
+ // No synced store here, or it is full. The value still has to live somewhere, so
+ // fall back to this device's own preferences rather than losing the choice.
+ com.codename1.io.Preferences.set("sortOrder", order);
+ }
+ }
+ // end::syncedStore[]
+
+ // tag::syncedStoreListener[]
+ public void followTheStore() {
+ SyncedStore.addChangeListener(new SyncedStoreListener() {
+ public void storeChanged() {
+ // No values are carried, on any platform. Re-read what this screen shows.
+ applySortOrder(SyncedStore.get("sortOrder", "byName"));
+ }
+ });
+ }
+ // end::syncedStoreListener[]
+
+ // tag::capability[]
+ public void describeWhatThisDeviceCanDo() {
+ if (Continuity.isContinuationSupported()) {
+ showBanner("Open this app on your other device to carry on there.");
+ }
+ }
+ // end::capability[]
+
+ // tag::logout[]
+ public void onLogout() {
+ Continuity.clear();
+ }
+ // end::logout[]
+
+ // tag::maxAge[]
+ public void expireACheckout() {
+ Continuity.setMaxAge(15 * 60 * 1000);
+ }
+ // end::maxAge[]
+
+ private void applySortOrder(String order) {
+ }
+
+ private void showBanner(String message) {
+ }
+}
diff --git a/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties
new file mode 100644
index 00000000000..60908c86b1f
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties
@@ -0,0 +1,9 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// tag::state-restoration-and-continuity-properties-001[]
+codename1.arg.ios.continuity.enabled=true
+// end::state-restoration-and-continuity-properties-001[]
+
+// tag::state-restoration-and-continuity-properties-002[]
+codename1.arg.ios.continuity.sync=false
+// end::state-restoration-and-continuity-properties-002[]
diff --git a/docs/developer-guide/State-Restoration-And-Continuity.asciidoc b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc
new file mode 100644
index 00000000000..89bdfa31932
--- /dev/null
+++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc
@@ -0,0 +1,306 @@
+== State Restoration and Continuity
+
+An app that's put in the background isn't paused. Android reclaims the process
+routinely, iOS terminates a suspended app whenever it needs the memory, and in
+both cases what comes back isn't the app the user left -- it's a fresh launch
+that happens to be wearing the same icon. The user sees their work replaced by
+the home screen and has no idea why.
+
+`com.codename1.continuity` saves what the user was doing and brings it back. On
+Apple platforms it does one thing more: it offers that same work to the other
+devices the person is signed in to, so a draft begun on the phone can be
+finished on the iPad they pick up.
+
+The two halves cost different things, so they're two packages.
+`com.codename1.continuity` holds the framework and everything that carries work
+to a device the user is holding. `com.codename1.continuity.sync` holds a small
+key/value store the platform keeps in step across their devices, and referencing
+it earns an iOS build an entitlement. An app that wants the first shouldn't have
+to arrange the second.
+
+[options="header"]
+|===
+| Capability | iOS and macOS | Android | Simulator and desktop | JavaScript
+| Restore after the process is killed | yes | yes | yes | yes
+| Restore the `@Route` screen stack | yes | yes | yes | yes
+| Carry on where they left off, on a device they're holding | yes | -- | simulated | --
+| A key/value store synced across devices | yes (`com.codename1.continuity.sync`) | -- | simulated | --
+| Carry state to any other device | your `StateRelay` | your `StateRelay` | your `StateRelay` | your `StateRelay`
+|===
+
+Branch on the capability queries -- `Continuity.isSupported()`,
+`Continuity.isContinuationSupported()`, `SyncedStore.isSupported()` -- rather
+than on platform detection. The first row is the one that matters most and it's
+supported everywhere, because it's pure storage with no platform behind it.
+
+Every callback in this family arrives on the EDT.
+
+=== Six Things Worth Knowing Before You Design Around This
+
+*Nothing happens until you ask for it.* An app that never references this package
+behaves exactly as it always did, and so does one that references it and never
+calls `Continuity.setStateProvider` or `Continuity.enable`. `Continuity.restore()`
+is never called for you either. Where restoration belongs in a launch is a
+decision only the app can make, and a framework that guessed would be wrong for
+the apps that care most.
+
+*The route stack is free; everything else is yours.* If your screens are declared
+with `@Route`, the framework already knows the navigation stack and restores it
+with no code from you. If your app navigates with `new MyForm().show()`, those
+moves aren't addressable and there's nothing to write down -- so `restore()`
+hands your payload to the `StateProvider` and answers `false`, leaving you to
+show a screen. Both are supported; only the first is automatic.
+
+*Saving happens continuously, not at shutdown.* Every navigation marks the state
+dirty and a checkpoint is written once per pass of the event loop, so by the time
+the operating system suspends the app the work is already done. Don't look for a
+place to save on exit; there isn't one worth using. Android blocks its own main
+thread until your `stop()` returns, so an app that did its saving there would pay
+for it on every suspend. Call `Continuity.checkpoint()` after changing something
+your provider reports that no navigation touched.
+
+*A payload has to survive leaving the device.* It's written to disk, handed to an
+operating system, and possibly delivered to a different device running a
+different build of your app -- so it admits only `String`, `Integer`, `Long`,
+`Double`, `Boolean`, and `List` and `Map` of those. Anything else is refused
+where you produced it, with a message naming the key, rather than becoming a
+value that stops arriving on the other end with nothing to say so.
+
+*Codename One runs no relay server.* Continuation between Apple devices is the
+platform's; anything else -- iPhone to Android, two devices that are never in the
+same room -- goes through a `StateRelay`, which is your endpoint. That isn't a
+gap to be filled later. Deciding which saved states belong to the same *person*
+is your account system's question, and a framework that answered it would be
+guessing about your users.
+
+*A continuation isn't secure storage.* What you put in the payload crosses to
+another device and is held by the operating system on the way. Tokens, keys and
+anything you would not want restored on a device that merely shares an account
+belong in `com.codename1.security.SecureStorage`, with the payload carrying at
+most an identifier that means nothing on its own.
+
+=== Saving and restoring
+
+Two pieces. A `StateProvider` supplies the half the framework can't work out --
+the scroll position, the half-typed message, the record being edited -- and
+installing one turns the framework on:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=provider,indent=0]
+----
+
+Then `start()` reads as "restore, or else begin":
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=start,indent=0]
+----
+
+`restore()` returns `true` when it put a screen up, so the caller knows not to
+show its own. It returns `false` when there was nothing to restore *and* when the
+state carried no routes -- the payload-only case above -- which is why the
+fallback branch belongs there rather than behind a null check.
+
+`restoreState` runs before the restored screens are built, so a form the route
+table is about to construct can read what the provider stashed while that form
+is being built.
+
+Take a checkpoint by hand after a change no navigation followed:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=checkpoint,indent=0]
+----
+
+The title is what a receiving device may show the user before they accept, so it
+should name the work rather than the screen -- `Draft to Dana`, not `Compose`.
+
+By default a saved state never expires, because an app the user opens after a
+month should still come back where they left it. Where coming back is only
+meaningful for a while -- a checkout, a booking hold, a queue position -- say so:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=maxAge,indent=0]
+----
+
+=== Continuing on another device
+
+Nothing extra is required for the Apple case. Every checkpoint advertises the
+current state, and a device the user is holding is offered it by the system. What
+you may want is to say so in the interface, which is what the capability query is
+for:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=capability,indent=0]
+----
+
+An arriving state is restored automatically. When moving the user is a decision
+your app should make -- they're midway through a payment, or the state belongs
+to a different account than the one signed in here -- take it yourself. A
+listener that returns `false` has consumed the state: nothing is restored and no
+other listener is asked, which is what makes holding it and asking work:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=askFirst,indent=0]
+----
+
+A state this device published is never offered back to its own listener, and a
+state already acted on is never acted on twice -- a continuation and a relay
+routinely carry the same one.
+
+=== Reaching every other device
+
+A `StateRelay` is your endpoint, and `RestStateRelay` covers the common case:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=relay,indent=0]
+----
+
+Two requests against the one URL. A `POST` carries the state as a JSON body,
+which you store against the signed-in user, replacing whatever you held for them.
+A `GET` answers with the newest state you hold for that user, or an empty body
+when you hold none. The JSON is a closed shape: your endpoint stores and returns
+the document and never needs to look inside it.
+
+The token comes from `getToken()` rather than from the constructor because it's
+read at every request, so a session that refreshes its token is followed with no
+further code.
+
+A relay is written to when the app checkpoints and read only when something asks,
+so ask when the app comes back to the foreground:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=pollOnResume,indent=0]
+----
+
+On Android that call is already made for you when the activity resumes. Making it
+yourself as well is harmless -- a state already seen is ignored.
+
+Put `Continuity.clear()` on your logout path. The advertised activity outlives
+your app's own screen, so an account's work would otherwise stay on offer to the
+devices around it after the user signed out:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0]
+----
+
+=== The synced store
+
+`com.codename1.continuity.sync.SyncedStore` is the slow, patient half: a handful
+of durable choices -- which theme, which sort order, which tutorial they already
+dismissed -- kept in step across the devices one person is signed in to, without
+those devices ever being near each other.
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStore,indent=0]
+----
+
+Note the shape: a read always has a default, and a write reports whether it
+happened. That isn't defensive style, it's the API being honest. The store is
+empty on a device that has never synced, the user can switch the whole mechanism
+off, and it exists on Apple platforms only -- so a synced value with a local
+default behind it makes the design work on every platform.
+
+Changes made elsewhere arrive without values, on every platform that has such a
+store at all, so re-read what your screen shows rather than assuming you know
+which key moved:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=syncedStoreListener,indent=0]
+----
+
+This isn't storage. It's small, the platform decides when to sync it, and
+nothing in it should be anything your app can't do without.
+
+=== Developing Without Hardware
+
+The simulator carries a simulated continuity platform, so all this works on
+the desktop -- and the `Simulate -> Continuity` menu scripts the cases that are
+otherwise reachable only with two devices in your hands. Every item is also
+callable from a test with `CN.execute("continuity:itemN")`.
+
+*Continue Here (As Another Device)* hands whatever the app is currently
+advertising straight back to it. That's the whole feature in one click. If it
+does nothing, the app hasn't taken a checkpoint yet -- which is itself the
+answer to the question of why nothing is being offered.
+
+The rest reproduce traps rather than the happy path:
+
+* *Continue A Route This Build Dropped.* A screen goes away in a rebuild and the
+states already sitting on the user's other devices still name it. The restore
+survives on the frames it can still build.
+* *Continue With No Routes (Payload Only).* What an app that doesn't use
+`@Route` produces. An app that assumed `restore()` always shows something finds
+out here.
+* *Continue Something From Yesterday.* Exercises `setMaxAge`, and the listener
+that has to decide whether moving the user somewhere they were yesterday is a
+courtesy or an ambush.
+* *Change The Synced Store Elsewhere.* The notification carries no values, so an
+app that re-reads only the key it assumed changed reads a stale one.
+* *Make The Synced Store Unsupported* and *Make Continuation Unsupported.* What
+every non-Apple platform reports. An app that put a required setting in the
+synced store and never checked `isSupported()` loses it here, with no error,
+exactly as it would on Android.
+
+=== Build Hints
+
+[options="header"]
+|===
+| Hint | Default | What it does
+| `ios.continuity.enabled` | `false` | Declares that this project hands work between devices. The build works this out from bytecode on its own; this exists because the Certificate Wizard and the signing preflight can't read bytecode and need to know whether an iCloud capability will be wanted.
+| `ios.continuity.sync` | `true` | Set `false` to skip the iCloud key-value store entitlement.
+|===
+
+[source,properties]
+----
+include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-001,indent=0]
+----
+
+Everything else is automatic. Referencing `com.codename1.continuity` compiles the
+`NSUserActivity` handling into the iOS build and declares this app's activity
+type in `NSUserActivityTypes`, which is what lets another device be offered the
+work -- iOS continues an activity only when the app declared its type, so an app
+that skipped this would publish states nobody is ever shown. Referencing
+`com.codename1.continuity.sync` additionally asks for the
+`com.apple.developer.ubiquity-kvstore-identifier` entitlement. Apps that touch
+neither package get none of it, on any platform. Android needs nothing injected
+at all: no permission, no manifest entry, no dependency.
+
+The activity type is your package name followed by `.continuity`, derived the
+same way in the build and at runtime, so there's nothing to configure and
+nothing to get out of step. `Continuity.getActivityType()` returns it, which is
+the first thing to check when a continuation never arrives.
+
+That entitlement is the one part of this that can stop a build. Apple grants it
+only through an App ID with the iCloud capability enabled, so a profile issued
+before that was switched on matches your bundle id and authorizes none
+of it -- and the build fails at codesigning, talking about an entitlement rather
+than about the capability. Codename One checks the profile before sending the
+build and warns, naming both ways out: enable iCloud on the App ID and regenerate
+the profile, or drop the entitlement.
+
+[source,properties]
+----
+include::../demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties[tag=state-restoration-and-continuity-properties-002,indent=0]
+----
+
+With that set, `SyncedStore.isSupported()` reports `false` at runtime and the
+rest of the app is unaffected. Handing work to a nearby device needs no
+entitlement and keeps working either way.
+
+WARNING: A restored state is restored on a device that has the app, not
+necessarily on the device that saved it and not necessarily by the person who
+did. Treat the payload as a description of *what screen to show*, never as proof
+of who is looking at it: re-check the signed-in account after restoring, and put
+nothing in a payload that would be a disclosure if it appeared on a family
+member's iPad. This is also what makes `Continuity.clear()` on logout more than
+housekeeping.
diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc
index 1b61a722511..8bab7094511 100644
--- a/docs/developer-guide/developer-guide.asciidoc
+++ b/docs/developer-guide/developer-guide.asciidoc
@@ -149,6 +149,8 @@ include::Printing.asciidoc[]
include::Deep-Links-Routing.asciidoc[]
+include::State-Restoration-And-Continuity.asciidoc[]
+
include::App-Intents.asciidoc[]
include::Document-Provider.asciidoc[]
diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json
index ef39d94799a..fd251c01eae 100644
--- a/docs/website/data/port_status.json
+++ b/docs/website/data/port_status.json
@@ -455,6 +455,15 @@
"SurfacesTimelineLogicTest"
]
},
+ {
+ "id": "state-restoration-continuity",
+ "category": "System surfaces",
+ "name": "State restoration and continuity",
+ "description": "Saves and restores what the user was doing, and checks the parts that have to behave identically on every port: the codec both wire formats share, the payload rule that lets a state cross to another device, the checkpoint, and the routeless restore that hands its payload back and shows nothing.",
+ "tests": [
+ "ContinuityStateTest"
+ ]
+ },
{
"id": "document-provider",
"category": "System surfaces",
diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java
index 29a3d695493..6e464a0d87a 100644
--- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java
+++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java
@@ -792,6 +792,30 @@ static void register(List h) {
+ "group, no plist keys -- leaving com.codename1.documents an inert no-op "
+ "at runtime."));
+ h.add(new Hint("ios.continuity.enabled")
+ .group(HintGroup.IOS)
+ .type(HintType.BOOLEAN)
+ .def("false")
+ .platform("ios")
+ .doc("Declares that this project hands the user's work between their devices. "
+ + "The build detects a reference to com.codename1.continuity on its own, "
+ + "so this is redundant for the build itself; it exists because the "
+ + "Certificate Wizard and the signing preflight work without reading "
+ + "bytecode and need to know whether an iCloud capability will be "
+ + "wanted."));
+
+ h.add(new Hint("ios.continuity.sync")
+ .group(HintGroup.IOS)
+ .type(HintType.BOOLEAN)
+ .def("true")
+ .platform("ios")
+ .doc("Set false to skip the iCloud key-value store entitlement that a reference "
+ + "to com.codename1.continuity.sync would otherwise earn. Use it when the "
+ + "App ID has no iCloud capability and the app can live without a synced "
+ + "store: SyncedStore then reports itself unsupported at runtime instead "
+ + "of the build failing to sign. Handing work to a nearby device is "
+ + "unaffected -- that half needs no entitlement."));
+
h.add(new Hint("ios.superfastBuild")
.group(HintGroup.IOS)
.type(HintType.BOOLEAN)
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
index 19d5a4247ad..8daba0b4a45 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java
@@ -1304,6 +1304,21 @@ private java.util.Set foldInCallAndVpnLibraryUsage(
// extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime.
private boolean surfacesExtensionEnabled;
+ // Set when the app references com.codename1.continuity. Gates the CN1_USE_CONTINUITY native
+ // define and this app's entry in NSUserActivityTypes -- which is all continuation costs,
+ // there being no entitlement behind NSUserActivity handoff.
+ //
+ // Note what this does NOT gate: saving and restoring state on the device is pure Java over
+ // com.codename1.io.Storage and works in every build. Only the cross-device half is here.
+ private boolean usesContinuity;
+
+ // Set when the app references com.codename1.continuity.sync -- deliberately narrower than
+ // usesContinuity, and for the reason usesHomeAccessoryData is narrower than usesSmartHome.
+ // The synced store is NSUbiquitousKeyValueStore, whose entitlement has to be granted on the
+ // App ID, so handing it to an app that only wanted to pass work to the tablet in the user's
+ // other hand would fail its codesigning for a capability it never asked for.
+ private boolean usesContinuitySync;
+
// Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native
// define, the CN1Documents file provider extension and the app group that lets the two
// processes meet.
@@ -2630,6 +2645,20 @@ public void usesClass(String cls) {
if (!usesDocuments && cls.indexOf("com/codename1/documents/") == 0) {
usesDocuments = true;
}
+ // State restoration and continuity (com.codename1.continuity.*). Gated on
+ // actual usage so the CN1_USE_CONTINUITY natives and the NSUserActivityTypes
+ // entry are only added for apps that hand work between devices.
+ if (!usesContinuity && cls.indexOf("com/codename1/continuity/") == 0) {
+ usesContinuity = true;
+ }
+ // The synced store, which is the only half that costs an entitlement. Its own
+ // package, so this prefix is a strict extension of the one above and both
+ // flags are set for an app that uses it -- which is correct: the store needs
+ // the native define too.
+ if (!usesContinuitySync
+ && cls.indexOf("com/codename1/continuity/sync/") == 0) {
+ usesContinuitySync = true;
+ }
// Phone-to-watch link (com.codename1.wearable.*). Gated on actual usage
// so WatchConnectivity.framework and the CN1_USE_WATCHCONNECTIVITY
// natives are only added for apps that talk to their watch app.
@@ -4061,6 +4090,19 @@ public void usesClassMethod(String cls, String method) {
replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_DOCUMENTS", "#define CN1_USE_DOCUMENTS");
}
+ // com.codename1.continuity usage compiles the NSUserActivity / NSUbiquitousKeyValueStore
+ // glue (gated by CN1_USE_CONTINUITY so other builds carry no such symbols), and opens
+ // the continuity branch in the app delegate. The define lives in the shared
+ // CodenameOne_GLViewController.h so it reaches every continuity translation unit,
+ // mirroring CN1_USE_INTENTS.
+ //
+ // Not gated on the sync opt-out below: the store reports its own availability at
+ // runtime from whether the entitlement actually granted one, and the continuation half
+ // needs these symbols regardless.
+ if (usesContinuity) {
+ replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_CONTINUITY", "#define CN1_USE_CONTINUITY");
+ }
+
// com.codename1.wearable usage compiles the WatchConnectivity glue (gated by
// CN1_USE_WATCHCONNECTIVITY so other builds carry no WCSession symbols). The define
// lives in the shared CodenameOne_GLViewController.h so it reaches every wearable
@@ -5394,6 +5436,38 @@ public void usesClassMethod(String cls, String method) {
+ " false. Remove the hint to build the rest of the"
+ " app.");
}
+ // The synced key/value store behind com.codename1.continuity.sync.
+ //
+ // Earned by the sync package alone, never by com.codename1.continuity. This
+ // entitlement has to be granted on the App ID before the app will sign at all,
+ // so giving it to an app that only hands work to a nearby device -- which costs
+ // nothing but a declared activity type -- would fail its codesigning for a
+ // capability it never asked for. Same reasoning as the HomeKit split above.
+ //
+ // The value is the two Xcode variables Apple documents for it rather than a
+ // literal, so it stays correct when the team or the bundle id changes, and so a
+ // build for a second team needs no edit here.
+ if (usesContinuitySync
+ && !"false".equals(request.getArg("ios.continuity.sync", "true"))) {
+ String kvStore = request.getArg("ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", null);
+ // A BLANK hint counts as absent, for the reason the VPN entitlement below
+ // spells out: buildNamespacedEntitlements skips an empty value entirely, so a
+ // project that set this to "" would suppress the generated entry and
+ // contribute nothing itself -- shipping an app whose SyncedStore silently
+ // stores nothing, which fails only at runtime on a device.
+ if (kvStore == null || kvStore.trim().length() == 0) {
+ request.putArgument("ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier",
+ "$(TeamIdentifierPrefix)$(CFBundleIdentifier)");
+ }
+ // An explicit non-blank value is left exactly as the project wrote it. Unlike
+ // the VPN entitlement there IS more than one legitimate value here -- an app
+ // sharing a store with a sibling app names that sibling's container -- so
+ // refusing anything but the default would break a configuration Apple
+ // supports.
+ }
+
// VPN configuration management.
//
// Both entitlements here are single-element arrays, which the
@@ -11542,6 +11616,21 @@ static String withSpotlightContinuation(String inject, boolean usesIntents) {
/// `IOSAppIntentsBuilder.publishesUserActivity` for why advertising the rest is not
/// harmlessly generous.
static String userActivityTypesKey(List
*/
List appGroups = new ArrayList();
+ /**
+ * True when the profile grants {@code com.apple.developer.ubiquity-kvstore-identifier},
+ * which is what an App ID with the iCloud capability enabled looks like.
+ *
+ *
Read the same way as {@link #appGroups}: false means the profile genuinely grants
+ * none, because a profile that could not be parsed at all produces no Profile.
+ */
+ boolean ubiquityKeyValueStore;
}
/** A problem found before the build was sent: {@code message} is written for the user. */
@@ -222,6 +230,75 @@ static List check(Properties settings, boolean release, Date now) {
return problems;
}
+ /**
+ * Whether the profile can sign an app that asks for the iCloud key-value store.
+ *
+ *
A reference to {@code com.codename1.continuity.sync} makes the build declare
+ * {@code com.apple.developer.ubiquity-kvstore-identifier}, and Apple grants that entitlement
+ * only through an App ID with the iCloud capability enabled. A profile issued before that was
+ * switched on matches the bundle id perfectly and still authorizes none of it, so the build
+ * runs all the way to codesign and fails there -- talking about an entitlement rather than
+ * about the iCloud capability nobody enabled.
+ *
+ *
Never fatal, and that is deliberate: unlike the App Group checks beside it, this
+ * entitlement has a documented opt-out. {@code ios.continuity.sync=false} drops it and leaves
+ * the app working with {@code SyncedStore.isSupported()} reporting false, so a warning that
+ * names the two ways out is more useful than a refusal.
+ *
+ * @return one problem when the profile demonstrably lacks the entitlement, none when it has
+ * it, the sync half is switched off, or nothing readable says either way
+ */
+ static List checkContinuitySync(Properties settings, boolean release) {
+ List problems = new ArrayList();
+ if (settings == null) {
+ return problems;
+ }
+ if (!"true".equals(trimmed(settings.getProperty(
+ "codename1.arg.ios.continuity.enabled")))) {
+ // The project has not said it wants a synced store. The builder decides this from
+ // bytecode, which this check cannot read -- so an app that uses the API without
+ // setting the hint is simply not checked here, and finds out at codesign as it does
+ // today. Guessing from anything else would warn projects that use no continuity at
+ // all.
+ return problems;
+ }
+ if ("false".equals(trimmed(settings.getProperty(
+ "codename1.arg.ios.continuity.sync")))) {
+ // Explicitly opted out: the build declares no entitlement, so there is nothing the
+ // profile has to grant.
+ return problems;
+ }
+ String override = trimmed(settings.getProperty("codename1.arg.ios.entitlements.com.apple"
+ + ".developer.ubiquity-kvstore-identifier"));
+ if (override != null && !override.isEmpty()) {
+ // The project named a container of its own, which is the shape of an app sharing a
+ // store with a sibling. Whether the profile grants that particular one is a question
+ // this cannot answer from the key alone, and warning on it would be noise.
+ return problems;
+ }
+ Profile appProfile = appProfile(settings, release);
+ if (appProfile == null || appProfile.applicationIdentifier == null) {
+ // No readable profile: check() reports that, and it is not something to warn about
+ // twice.
+ return problems;
+ }
+ if (appProfile.ubiquityKeyValueStore) {
+ return problems;
+ }
+ problems.add(new Problem("This app uses com.codename1.continuity.sync, so the build asks "
+ + "for the iCloud key-value store entitlement "
+ + "(com.apple.developer.ubiquity-kvstore-identifier) -- and the provisioning "
+ + "profile \"" + appProfile.name + "\" does not grant it.\n"
+ + "Apple grants it only through an App ID with the iCloud capability enabled, so "
+ + "signing will fail on the entitlement rather than on the profile name.\n"
+ + "Either enable iCloud on the App ID at developer.apple.com and regenerate the "
+ + "profile, or set codename1.arg.ios.continuity.sync=false -- which drops the "
+ + "entitlement and leaves SyncedStore reporting itself unsupported at runtime. "
+ + "Handing work to a nearby device is unaffected either way; that half needs no "
+ + "entitlement.", false));
+ return problems;
+ }
+
/**
* Whether every app extension this build embeds can actually be signed.
*
@@ -1005,6 +1082,11 @@ static Profile parse(byte[] raw) throws Exception {
}
}
}
+ // Same nesting again: this is what says whether the profile can sign a target that asks
+ // for the iCloud key-value store, which a reference to com.codename1.continuity.sync
+ // makes the build declare.
+ Element ubiquity = valueForKey(doc, "com.apple.developer.ubiquity-kvstore-identifier");
+ profile.ubiquityKeyValueStore = ubiquity != null;
profile.type = deriveType(doc);
return profile;
}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java
new file mode 100644
index 00000000000..280226de9f8
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java
@@ -0,0 +1,195 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.builders;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@code NSUserActivityTypes} has two contributors, and there can only be one key.
+ *
+ *
App intents and continuity both need this key, an app that uses both is ordinary, and a
+ * property list carrying it twice is one iOS reads unpredictably. The interesting cases are
+ * therefore all about the two meeting: each alone, both together, and both on top of an array the
+ * application already declared through {@code ios.plistInject}.
+ *
+ *
The failure this prevents is silent on both sides. iOS only continues an activity whose type
+ * the app declared here, so a missing entry is a feature that does nothing at all -- on two
+ * devices, with nothing logged anywhere.
+ */
+class IPhoneBuilderContinuityPlistTest {
+
+ private static final String CONTINUITY_TYPE = "com.example.app.continuity";
+
+ private static Map intent(String id) {
+ Map m = new HashMap();
+ m.put("id", id);
+ m.put("assistant", Boolean.TRUE);
+ return m;
+ }
+
+ private static List> intents(String... ids) {
+ List> out = new ArrayList>();
+ for (String id : ids) {
+ out.add(intent(id));
+ }
+ return out;
+ }
+
+ private static List> noIntents() {
+ return new ArrayList>();
+ }
+
+ private static int occurrences(String haystack, String needle) {
+ int count = 0;
+ int at = haystack.indexOf(needle);
+ while (at >= 0) {
+ count++;
+ at = haystack.indexOf(needle, at + needle.length());
+ }
+ return count;
+ }
+
+ // ------------------------------------------------------------------
+ // Emitting the key
+ // ------------------------------------------------------------------
+
+ @Test
+ void continuityAloneDeclaresItsActivityType() {
+ String key = IPhoneBuilder.userActivityTypesKey(noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(key.contains("NSUserActivityTypes"), key);
+ assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key);
+ assertEquals(1, occurrences(key, "NSUserActivityTypes"), key);
+ }
+
+ @Test
+ void intentsAndContinuityShareOneKey() {
+ String key = IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), CONTINUITY_TYPE);
+
+ assertEquals(1, occurrences(key, "NSUserActivityTypes"), key);
+ assertEquals(1, occurrences(key, ""), key);
+ assertTrue(key.contains("logWorkout"), key);
+ assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key);
+ }
+
+ @Test
+ void intentsAloneAreUnchangedByTheContinuityParameter() {
+ assertEquals(IPhoneBuilder.userActivityTypesKey(intents("logWorkout")),
+ IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), null));
+ }
+
+ /**
+ * An app with nothing to declare writes nothing. An empty array would state that the app
+ * continues no activity at all, into the plist of an app that may well continue its own.
+ */
+ @Test
+ void nothingToDeclareWritesNoKey() {
+ assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), null));
+ assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), ""));
+ }
+
+ // ------------------------------------------------------------------
+ // Merging into an array the application supplied
+ // ------------------------------------------------------------------
+
+ @Test
+ void continuityMergesIntoAnArrayTheApplicationDeclared() {
+ String inject = "NSUserActivityTypes"
+ + "com.example.app.legacyHandoff";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged);
+ assertTrue(merged.contains("com.example.app.legacyHandoff"), merged);
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged);
+ }
+
+ @Test
+ void intentsAndContinuityBothMergeIntoOneSuppliedArray() {
+ String inject = "NSUserActivityTypes"
+ + "com.example.app.legacyHandoff";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"),
+ CONTINUITY_TYPE);
+
+ assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged);
+ assertEquals(1, occurrences(merged, ""), merged);
+ assertTrue(merged.contains("com.example.app.legacyHandoff"), merged);
+ assertTrue(merged.contains("logWorkout"), merged);
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged);
+ }
+
+ /**
+ * A project that already named the continuity type itself gets it once, not twice.
+ */
+ @Test
+ void anAlreadyDeclaredContinuityTypeIsNotDuplicated() {
+ String inject = "NSUserActivityTypes"
+ + "" + CONTINUITY_TYPE + "";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ assertEquals(1, occurrences(merged, "" + CONTINUITY_TYPE + ""), merged);
+ }
+
+ @Test
+ void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() {
+ String inject = "NSUserActivityTypesnot an array";
+
+ assertEquals(inject,
+ IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE));
+ }
+
+ /**
+ * The parser has to accept the shapes a hand-written fragment really carries.
+ */
+ @Test
+ void aSpacedClosingTagIsStillMergedInto() {
+ String inject = "NSUserActivityTypes"
+ + "com.example.app.legacyHandoff";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged);
+ assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged);
+ }
+
+ @Test
+ void nothingToAddLeavesTheFragmentAlone() {
+ String inject = "NSUserActivityTypes"
+ + "com.example.app.legacyHandoff";
+
+ assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null));
+ assertFalse(IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null)
+ .contains("null"));
+ }
+}
diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java
new file mode 100644
index 00000000000..137f7df1522
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.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.maven;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * An app that uses {@code com.codename1.continuity.sync} asks for the iCloud key-value store
+ * entitlement, and Apple grants that only through an App ID with the iCloud capability enabled.
+ *
+ *
A profile issued before that was switched on matches the bundle id perfectly and authorizes
+ * none of it, so the build runs all the way to codesign and fails there -- talking about an
+ * entitlement rather than about the capability nobody enabled. Everything needed to say so is on
+ * disk before the build is sent.
+ *
+ *
Warned about rather than refused, unlike the App Group checks beside it: this entitlement has
+ * a documented opt-out ({@code ios.continuity.sync=false}) that leaves the app working, so naming
+ * the two ways out is more useful than a refusal.
+ */
+public class IOSContinuitySyncPreflightTest {
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static final String HEAD = "\n"
+ + "\n"
+ + "\n";
+
+ /// @param ubiquity true for a profile issued from an App ID with iCloud enabled
+ private File profile(String name, boolean ubiquity) throws Exception {
+ String kvStore = ubiquity
+ ? "com.apple.developer.ubiquity-kvstore-identifier"
+ + "ABCD1234.com.example.app"
+ : "";
+ String plist = HEAD
+ + "Name" + name + "\n"
+ + "UUID0f7ac3c1-4d0e-4e8a-9d1f-8b6a2c5e7d90\n"
+ + "ExpirationDate2099-01-01T00:00:00Z\n"
+ + "DeveloperCertificatesZm9v\n"
+ + "Entitlements"
+ + "application-identifier"
+ + "ABCD1234.com.example.app"
+ + kvStore
+ + "get-task-allow\n"
+ + "";
+ byte[] payload = plist.getBytes("UTF-8");
+ // The parser skips a binary preamble, exactly as a real signed profile carries one.
+ byte[] wrapped = new byte[payload.length + 24];
+ for (int i = 0; i < 16; i++) {
+ wrapped[i] = (byte) (0x80 + i);
+ }
+ System.arraycopy(payload, 0, wrapped, 16, payload.length);
+ File f = tmp.newFile(name + ".mobileprovision");
+ OutputStream out = new FileOutputStream(f);
+ try {
+ out.write(wrapped);
+ } finally {
+ out.close();
+ }
+ return f;
+ }
+
+ private Properties settings(File appProfile) throws Exception {
+ Properties p = new Properties();
+ p.setProperty("codename1.packageName", "com.example.app");
+ p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true),
+ appProfile.getAbsolutePath());
+ p.setProperty("codename1.arg.ios.continuity.enabled", "true");
+ return p;
+ }
+
+ private static List check(Properties p) {
+ return IOSProvisioningPreflight.checkContinuitySync(p, true);
+ }
+
+ @Test
+ public void aProfileWithoutTheEntitlementIsWarnedAbout() throws Exception {
+ List problems = check(settings(profile("NoCloud", false)));
+
+ assertEquals(1, problems.size());
+ assertTrue(problems.get(0).message.contains("ubiquity-kvstore-identifier"));
+ // Both ways out are named, because either is a legitimate answer.
+ assertTrue(problems.get(0).message.contains("iCloud"));
+ assertTrue(problems.get(0).message.contains("ios.continuity.sync=false"));
+ assertFalse("a documented opt-out exists, so this must not refuse the build",
+ problems.get(0).fatal);
+ }
+
+ @Test
+ public void aProfileWithTheEntitlementPassesQuietly() throws Exception {
+ assertTrue(check(settings(profile("WithCloud", true))).isEmpty());
+ }
+
+ @Test
+ public void theOptOutSkipsTheCheckEntirely() throws Exception {
+ Properties p = settings(profile("NoCloud", false));
+ p.setProperty("codename1.arg.ios.continuity.sync", "false");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /**
+ * A project that has not said it uses continuity is not checked. The builder decides that
+ * from bytecode, which this cannot read -- and guessing from anything else would warn
+ * projects that use none of it.
+ */
+ @Test
+ public void aProjectThatDeclaresNoContinuityIsNotChecked() throws Exception {
+ Properties p = settings(profile("NoCloud", false));
+ p.remove("codename1.arg.ios.continuity.enabled");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /**
+ * An app sharing a store with a sibling names that sibling's container. Whether the profile
+ * grants that particular one is not a question this can answer from the key alone.
+ */
+ @Test
+ public void anExplicitContainerIsLeftAlone() throws Exception {
+ Properties p = settings(profile("NoCloud", false));
+ p.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /** No readable profile is reported by check(), and is not something to warn about twice. */
+ @Test
+ public void anUnreadableProfileIsLeftToTheOtherChecks() throws Exception {
+ Properties p = new Properties();
+ p.setProperty("codename1.packageName", "com.example.app");
+ p.setProperty("codename1.arg.ios.continuity.enabled", "true");
+ p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true),
+ "/nowhere/missing.mobileprovision");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ @Test
+ public void nullSettingsProduceNoProblems() {
+ assertTrue(IOSProvisioningPreflight.checkContinuitySync(null, true).isEmpty());
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java
new file mode 100644
index 00000000000..b04d59aa067
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java
@@ -0,0 +1,266 @@
+/*
+ * 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.continuity;
+
+import com.codename1.io.Util;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The two wire formats an {@link AppState} has to survive, and the payload rule that makes both
+ * possible.
+ *
+ *
A state is written to storage on this device, handed to an operating system that may deliver
+ * it to another one, and sent through a relay to a device that may not be running the same build.
+ * Every one of those is lossy for something, so what is admitted into a payload is deliberately
+ * narrow -- and the point of these tests is that the narrowness is enforced where the application
+ * can act on it rather than discovered as a value that stopped arriving.
+ */
+public class AppStateWireTest {
+
+ @Test
+ public void jsonRoundTripPreservesEveryField() throws Exception {
+ AppState state = sample();
+
+ AppState back = StateCodec.fromJson(StateCodec.toJson(state));
+
+ assertNotNull(back);
+ assertEquals(Arrays.asList("/home", "/users/42"), back.getRoutes());
+ assertEquals("Ada", back.getPayload().get("name"));
+ assertEquals("device-a", back.getDeviceId());
+ assertEquals("Editing Ada", back.getTitle());
+ assertEquals(7L, back.getSequence());
+ assertEquals(1700000000123L, back.getTimestamp());
+ }
+
+ /**
+ * The reason the sequence and timestamp are encoded as strings.
+ *
+ *
JSON has one number type and {@code JSONParser} reads every one of them back as a
+ * {@code Double}. A millisecond timestamp is past the range a double represents exactly, so a
+ * numeric encoding would come back changed -- and only on the relay path, leaving a state that
+ * no longer compares equal to the one the same device published through a continuation.
+ */
+ @Test
+ public void aMillisecondTimestampSurvivesJsonExactly() throws Exception {
+ AppState state = new AppState().setTimestamp(1763512345678L).setSequence(9007199254740993L);
+
+ AppState back = StateCodec.fromJson(StateCodec.toJson(state));
+
+ assertEquals(1763512345678L, back.getTimestamp());
+ assertEquals(9007199254740993L, back.getSequence());
+ }
+
+ @Test
+ public void externalizableRoundTripPreservesEveryField() throws Exception {
+ Util.register(AppState.OBJECT_ID, AppState.class);
+ AppState state = sample();
+
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ DataOutputStream out = new DataOutputStream(bytes);
+ Util.writeObject(state, out);
+ out.close();
+ Object read = Util.readObject(
+ new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())));
+
+ assertTrue(read instanceof AppState);
+ AppState back = (AppState) read;
+ assertEquals(Arrays.asList("/home", "/users/42"), back.getRoutes());
+ assertEquals("Ada", back.getPayload().get("name"));
+ assertEquals("device-a", back.getDeviceId());
+ assertEquals("Editing Ada", back.getTitle());
+ assertEquals(7L, back.getSequence());
+ assertEquals(1700000000123L, back.getTimestamp());
+ }
+
+ @Test
+ public void aNestedPayloadSurvivesTheMapForm() {
+ Map inner = new HashMap();
+ inner.put("street", "Sesame");
+ List