diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java
new file mode 100644
index 00000000000..524dd3e4b85
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/AppState.java
@@ -0,0 +1,473 @@
+/*
+ * 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.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();
+ /// The lifecycle generation this state was DELIVERED in, or -1 for one the application
+ /// built or read back from storage.
+ ///
+ /// Not part of the wire form and not part of the stored form: it describes a session of this
+ /// process, not the state, and a state that outlived the session it arrived in is exactly
+ /// what it exists to catch.
+ private int deliveredGeneration = -1;
+
+ private String deviceId = "";
+ private String title;
+ private long sequence;
+ private long timestamp;
+
+ /// 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) {
+ int index = 0;
+ for (String path : r) {
+ if (path != null && path.length() > 0) {
+ // Every string this class writes goes through Util.writeUTF, and a route is
+ // not obviously short: a deep link carrying a query value reaches the limit
+ // as easily as a payload does. Validating only the payload left externalize()
+ // able to throw on a route, which persist() logs and carries on from -- so
+ // the checkpoint was published to the other device and silently absent from
+ // local storage, and restoration after process death did nothing.
+ StateCodec.requireWritable(path, "route[" + index + "]");
+ routes.add(path);
+ }
+ index++;
+ }
+ }
+ return this;
+ }
+
+ /// The generation this state was delivered in, or -1 when this framework never delivered it.
+ int deliveredGeneration() {
+ return deliveredGeneration;
+ }
+
+ /// Stamped by dispatch(), the one place a state is handed to the application.
+ void deliveredGeneration(int generation) {
+ deliveredGeneration = generation;
+ }
+
+ /// The application payload. Never null, possibly empty.
+ ///
+ /// The view is unmodifiable ALL THE WAY DOWN. Wrapping only the outer map left every nested
+ /// List and Map mutable, which matters most for an arrival: the same AppState handed to a
+ /// listener or a provider is afterwards parked, persisted, acknowledged and published, so a
+ /// caller that consumed a nested list -- removing items as it applied them, which is an
+ /// ordinary way to write that loop -- changed the framework's own snapshot of what arrived.
+ /// setPayload() deep-copies on the way in for exactly this reason; the way out needed to
+ /// match.
+ ///
+ /// #### Returns
+ ///
+ /// an unmodifiable view of the payload
+ public Map getPayload() {
+ return unmodifiableValues(payload);
+ }
+
+ /// The payload itself, for framework code that only reads it. Wrapping is not free and the
+ /// callers inside this package are not the ones the wrapping protects against.
+ Map payloadRef() {
+ return payload;
+ }
+
+ /// `value`, with every List and Map inside it wrapped as unmodifiable.
+ private static Object unmodifiableValue(Object value) {
+ if (value instanceof List) {
+ List> in = (List>) value;
+ List out = new ArrayList();
+ for (Object element : in) {
+ out.add(unmodifiableValue(element));
+ }
+ return Collections.unmodifiableList(out);
+ }
+ if (value instanceof Map) {
+ // Never a cast to Map: on the iOS virtual machine a failed cast does
+ // not throw, so the guarded instanceof above is the only portable way to ask, and the
+ // wildcard read below is what the compiler can actually check.
+ return unmodifiableValues(castValues((Map, ?>) value));
+ }
+ // Everything else a payload may hold -- String, Integer, Long, Double, Boolean -- is
+ // immutable already.
+ return value;
+ }
+
+ private static Map unmodifiableValues(Map in) {
+ Map out = new HashMap();
+ for (Map.Entry e : in.entrySet()) {
+ out.put(e.getKey(), unmodifiableValue(e.getValue()));
+ }
+ return Collections.unmodifiableMap(out);
+ }
+
+ private static Map castValues(Map, ?> in) {
+ Map out = new HashMap();
+ for (Map.Entry, ?> e : in.entrySet()) {
+ if (e.getKey() instanceof String) {
+ out.put((String) e.getKey(), e.getValue());
+ }
+ }
+ return out;
+ }
+
+ /// 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 = deepCopy(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 = deepCopy(p);
+ }
+
+ /// The other three fields a REMOTE document supplies, set without the local size validation.
+ ///
+ /// Same reason as the payload beside them, and they were simply missed: a document from
+ /// another device goes through the validating setters, so one route longer than this device's
+ /// stored-string limit threw IllegalArgumentException out of StateCodec.fromJson -- which
+ /// fromJson does not document and the relay reads as a FAILED fetch. The document never
+ /// changes, so every retry fails identically and this device stops publishing for good.
+ ///
+ /// Carried rather than refused, because the limit is about writing: a state that cannot be
+ /// stored here can still be restored here, and persist() already reports its own failure to
+ /// the one caller that must not act on it.
+ void setRoutesUnchecked(List r) {
+ routes = r == null ? new ArrayList() : new ArrayList(r);
+ }
+
+ void setDeviceIdUnchecked(String id) {
+ deviceId = id == null ? "" : id;
+ }
+
+ void setTitleUnchecked(String t) {
+ title = t;
+ }
+
+ /// Copies a payload all the way down, not just its outer map.
+ ///
+ /// A shallow copy left the snapshot sharing the application's own lists and maps. That is a
+ /// race with a silent result, because a state outlives the call that produced it: the relay
+ /// serializes it later on a background thread, so an edit the application makes in between
+ /// could publish newer contents under an older sequence number, or throw a
+ /// ConcurrentModificationException in the middle of a checkpoint. A snapshot has to be a
+ /// snapshot.
+ ///
+ /// Only the container types are rebuilt. Everything else a payload may hold -- String,
+ /// Integer, Long, Double, Boolean -- is immutable, so copying it would buy nothing.
+ private static Map deepCopy(Map p) {
+ Map out = new HashMap();
+ if (p == null) {
+ return out;
+ }
+ for (Map.Entry e : p.entrySet()) {
+ out.put(e.getKey(), copyValue(e.getValue()));
+ }
+ return out;
+ }
+
+ private static Object copyValue(Object value) {
+ if (value instanceof List) {
+ List> in = (List>) value;
+ List out = new ArrayList();
+ for (Object element : in) {
+ out.add(copyValue(element));
+ }
+ return out;
+ }
+ if (value instanceof Map) {
+ Map, ?> in = (Map, ?>) value;
+ Map out = new HashMap();
+ for (Map.Entry, ?> e : in.entrySet()) {
+ if (e.getKey() instanceof String) {
+ out.put((String) e.getKey(), copyValue(e.getValue()));
+ }
+ }
+ return out;
+ }
+ return value;
+ }
+
+ /// 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) {
+ if (id != null) {
+ // Framework-generated in every path we own, and validated anyway: a port supplying its
+ // own id writes it through the same writeUTF as everything else here.
+ StateCodec.requireWritable(id, "deviceId");
+ }
+ 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) {
+ if (t != null) {
+ // Application-supplied, so this is the one of the three most likely to be long.
+ StateCodec.requireWritable(t, "title");
+ }
+ 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 (String path : routes) {
+ Util.writeUTF(path, 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 (Map.Entry, ?> entry : read.entrySet()) {
+ 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..339f25135f1
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/Continuity.java
@@ -0,0 +1,4152 @@
+/*
+ * 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.Storage;
+import com.codename1.io.Util;
+import com.codename1.router.Navigation;
+import com.codename1.ui.Display;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+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.
+///
+/// #### Threading
+///
+/// Call this class from the event dispatch thread, like the rest of the toolkit. Codename One is
+/// single threaded: every method here runs on the EDT and every field it keeps is owned by the
+/// EDT, so there is nothing to synchronize and nothing that can interleave.
+///
+/// Two kinds of foreign thread exist, and both hand over at the boundary rather than reaching in.
+/// A port delivering a continuation arrives on the platform's own thread and is marshalled with
+/// `com.codename1.ui.Display#callSerially`. The relay's `publish` and `fetch` are blocking calls
+/// that must not sit on the EDT, so they run on a worker -- one that is handed the state it needs
+/// as a parameter, touches no field of this class, and returns its answer through `callSerially`
+/// as well. That is the whole concurrency design, and it is deliberately the toolkit's: one
+/// thread on each side of a boundary, never two on the same state.
+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.
+ /// Every value below lives in Storage, not Preferences, and the names are kept only because
+ /// they are what the data was already called.
+ ///
+ /// Preferences cannot say whether a write reached the disk. set() puts the value in a static
+ /// Hashtable and then calls save(), which discards Storage.writeObject()'s result -- and
+ /// get() reads that same Hashtable, so reading a value back after writing it confirms the
+ /// cache and nothing else. A verification built that way was added here once and could not
+ /// detect the failure it was written for.
+ ///
+ /// These three all have durable meaning: an id that changes across a restart makes every
+ /// state this device sent look foreign, a counter that reloads lower has receivers refusing
+ /// this device until it catches up, and a mark that never lands lets an acknowledged state be
+ /// acted on twice. Storage.writeObject returns a boolean, so the failure is at least visible.
+ 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";
+
+ /// Where the per-device delivery high-water marks live between runs.
+ static final String PREF_SEEN = "CN1$ContinuitySeen";
+
+ /// How many devices' marks are kept.
+ ///
+ /// A user has a handful of devices, but the ids come off a relay and nothing stops one from
+ /// feeding many, so this is bounded. When it overflows the LOWEST sequences go: those are the
+ /// devices that have been quiet longest, and losing a mark costs one duplicate delivery rather
+ /// than anything durable.
+ private static final int MAX_SEEN = 64;
+
+ /// How many displaced arrivals the shelf keeps.
+ ///
+ /// Far smaller than MAX_SEEN, which bounds a map of longs: this one holds whole states,
+ /// payloads included, and the device ids that key it come off the wire. An unbounded shelf
+ /// lets a peer decide how much memory this process uses, so it is bounded and the OLDEST goes
+ /// -- the newest work is the work the user is most likely to still want. One entry per device
+ /// means this is a count of the user's devices, and eight is past any real account.
+ private static final int MAX_SHELVED = 8;
+
+ /// When a continuation payload is worth mentioning. Advisory only -- see
+ /// warnIfLargeForHandoff, which reports and never refuses.
+ private static final int HANDOFF_ADVISORY_CHARS = 3072;
+
+ /// Whether the size above has already been reported this session.
+ private static boolean handoffSizeReported;
+
+ /// 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.
+ /// Insertion-ordered, so the cap can evict the device that has been quiet longest.
+ ///
+ /// A HashMap forced the eviction to pick a victim by comparing SEQUENCES, and sequences are
+ /// each origin's own counter -- a device at 5000 is not busier than one at 3, it has simply
+ /// been counting longer. Worse, the lowest sequence in a full map is usually a device that
+ /// has just been set up and sent its first state, so admitting it evicted it immediately and
+ /// the dispatch queued behind admit() found its mark gone and dropped a perfectly good
+ /// continuation without a word.
+ private static final Map lastSeen = new LinkedHashMap();
+
+ /// The marks that are allowed to reach storage: states this device actually COMPLETED.
+ ///
+ /// Separate from `lastSeen`, which holds every state that was admitted, because those two
+ /// sets are not the same and writing the wrong one throws states away. A state admitted and
+ /// then failed -- a provider that threw, routes that could not be rebuilt -- stays in
+ /// lastSeen so it is not re-dispatched twice in this run, and must NOT become durable:
+ /// serializing the whole map meant an unrelated state completing later carried the failed
+ /// one to disk with it, and after a restart the relay's only usable copy was refused. That
+ /// is exactly the gating commit() performs, undone by the writer.
+ private static final Map durableSeen = new LinkedHashMap();
+
+ // EDT-owned, like every field in this class. See the threading note on the class.
+ private static StateProvider provider;
+ private static StateRelay relay;
+ private static ContinuityBridge bridge;
+ private static boolean bridgeOverridden;
+ private static boolean enabled;
+ private static boolean autoRestore = true;
+ private static boolean flushScheduled;
+
+ /// True while an inbound state is being applied, so the navigation it causes is not mistaken
+ /// for the user moving and republished.
+ private static boolean applyingRestore;
+
+ /// True once a synced-store listener has asked for the inbound seam, independently of
+ /// `enabled`.
+ private static boolean storeCallbackInstalled;
+ private static String title;
+ private static long sequence;
+ private static long maxAge;
+
+ /// The device id, lazily created.
+ private static String deviceId;
+
+ /// Whether a checkpoint is owed.
+ private static boolean dirty;
+
+ /// True while the cold-launch waiter is running, so a second arrival does not start another.
+ private static boolean waitingForWindow;
+
+ /// A state that arrived and could not be shown yet.
+ private static AppState parked;
+
+ /// Arrivals displaced from that slot by a state from a DIFFERENT device, newest per origin.
+ ///
+ /// The slot answers getRestorableState(), and one slot is the right shape for that -- the
+ /// application is asked about one thing at a time. It is the wrong shape for HOLDING, and
+ /// those are two different jobs that used to share the field. Two devices can each offer work
+ /// while automatic restoration is off, and the second arrival simply overwrote the first.
+ ///
+ /// That was survivable for as long as the port still had its copy: the framework declined
+ /// off-EDT, the port kept it, and installing a callback re-offered it. It stopped being
+ /// survivable when the callback started CLAIMING what it queues -- which it has to, because
+ /// the decision is made later on the EDT and the answer is owed now -- since a conforming
+ /// bridge is then entitled to drop its copy. Nothing would ever deliver that state again.
+ ///
+ /// So displacement shelves rather than drops, and getRestorableState() promotes the newest
+ /// shelved arrival once the slot empties. Bounded by how many devices the user has, one entry
+ /// each, aged out by getMaxAge() like everything else that waits.
+ private static final Map shelved = new LinkedHashMap();
+
+ /// Which relay session the in-flight worker belongs to, bumped by `clear()`, `setRelay()` and
+ /// `reset()`.
+ ///
+ /// Ordinary bookkeeping rather than a memory-model device: a relay round trip is the one
+ /// thing here that outlives the EDT turn that started it, so a fetch begun before a logout
+ /// can return after one. The worker carries the session it was started in and the completion
+ /// -- which runs back on the EDT -- ignores an answer whose session has moved on, rather than
+ /// delivering the previous account's state into the next account's screen.
+ private static int relaySession;
+
+ /// What was on screen at the instant the session last ended.
+ ///
+ /// The restore undo needs to tell "the restore put this signed-out screen up" from "the
+ /// application chose this screen when it signed out", and those happen in the same call:
+ /// clear() is reached from INSIDE Navigation.restoreStack(), through a route factory or a
+ /// show callback, so by the time restoreStack returns whatever the application did is already
+ /// current and looks exactly like what the restore did. Sampling the display at the moment
+ /// the session ended is the one point where the two are still distinguishable.
+ /// The screen that was showing when a session ended DURING a restore, or null.
+ ///
+ /// Recorded only while `applyingRestore` is set, and dropped as soon as the comparison that
+ /// wants it has run. Both halves are about what this field is: a strong reference to a Form,
+ /// which is a whole component tree and whatever the application hung off it.
+ ///
+ /// An ordinary logout used to fill it too -- clear() records the current screen whether or not
+ /// anything is restoring -- and nothing ever read it in that case, so the signed-out account's
+ /// entire UI stayed reachable through a static for the whole of the next session. The one
+ /// reader is applicationChoseTheScreen(), which exists for a route factory or a show callback
+ /// that ends the session from inside restoreStack(); outside that window there is no
+ /// comparison to make and no reason to hold the form.
+ private static com.codename1.ui.Form formAtSessionEnd;
+
+ /// Armed only while clear() drains whatever the port has been holding, and read by the
+ /// callback the port offers it to. Confined to that call: the offer is synchronous, on the
+ /// event thread, and the flag is lowered in a finally.
+ private static boolean discardHeldArrival;
+
+ /// Whether the application has said anything about continuity yet -- either enable() or
+ /// disable(). It is NOT the negation of `enabled`: the two states that share
+ /// `enabled == false` -- nothing said yet, and switched off on purpose -- want opposite
+ /// answers for an arrival, and telling them apart is the whole reason this exists.
+ ///
+ /// Set by disable() as well as enable(), because "no" is an answer. Reading it as
+ /// "enable() has run" left an application that only enables after a login treating its own
+ /// explicit disable() as though it had never spoken.
+ private static boolean applicationHasChosen;
+
+ /// The bridge this framework's callback was last given to, or null.
+ ///
+ /// The INSTANCE rather than a boolean, because both questions have to be answered by one
+ /// field: a second install on the same bridge is the bug -- ContinuityBridge documents that
+ /// setCallback is called once, and a port that registers a native observer there ends up
+ /// with several, delivering every store change as many times as there are listeners -- while
+ /// a bridge the port has SWAPPED must be given one, which is the whole job of
+ /// refreshBridge().
+ private static ContinuityBridge callbackInstalledOn;
+
+ /// The relay session a framework worker is running for, bound for the length of its call
+ /// into the relay and unbound afterwards. Null on the event thread and on any thread the
+ /// application drives itself, which is how mayRelaySend() tells the two apart.
+ ///
+ /// A thread local rather than a static, because two workers can be in flight at once: a poll
+ /// starts while a publish is on the wire -- startPoll() guards against a second READ, not
+ /// against a write -- so one field would answer for whichever worker wrote it last.
+ private static final ThreadLocal RELAY_CALL_SESSION = new ThreadLocal();
+
+ 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;
+ }
+ // 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);
+ // getDeviceId() rather than loadDeviceId(): it is the one that mints and persists a UUID,
+ // so the id this device sends is the id it will still be using after a restart.
+ deviceId = getDeviceId();
+ sequence = loadSequence();
+ // Merged rather than replaced. A mark already in memory is from this run and is at least
+ // as new as the stored one.
+ Map restored = readSeen();
+ for (Map.Entry e : restored.entrySet()) {
+ // The two maps are advanced INDEPENDENTLY, because they can disagree and gating one
+ // on the other loses a mark. A loaded value describes a state a previous run
+ // COMPLETED, so it belongs in the durable set even when the in-memory set already
+ // holds something newer from this run -- deciding both on the lastSeen comparison
+ // alone dropped it, and the durable set is the one that survives the next restart.
+ //
+ // Found by auditing the read sites after a review found the same divergence at
+ // admission, rather than waiting for it to be reported.
+ long loaded = e.getValue().longValue();
+ Long inMemory = lastSeen.get(e.getKey());
+ if (inMemory == null || inMemory.longValue() < loaded) {
+ recordSeen(e.getKey(), loaded, false);
+ }
+ Long durable = durableSeen.get(e.getKey());
+ if (durable == null || durable.longValue() < loaded) {
+ recordDurable(e.getKey(), loaded);
+ }
+ }
+ // A NEW generation, because this is a new session and the off period had one of its own.
+ //
+ // clear() and disable() each advance it and enabling did not, so the interval between a
+ // logout and the login that follows carried the SAME generation as the session after it.
+ // An arrival that reached the callback while continuity was off captured that generation
+ // and queued its decision; the decision then ran after this method had set `enabled` back
+ // to true, found the generation it captured still current, and admitted the previous
+ // account's work into the one that just signed in. The callback had already answered
+ // "claimed", so the bridge was entitled to drop the only other copy.
+ //
+ // Advancing here is what makes the off period a session of its own: anything sampled
+ // during it is now stale, which is exactly what it is.
+ //
+ // BEFORE installCallback below, and that ordering is the whole of the distinction. A
+ // continuation the port DECLINED earlier and is still holding is re-offered during that
+ // call, samples the generation as it stands now, and is admitted -- which is what enabling
+ // is meant to pick up. One that was claimed and queued during the off period sampled the
+ // old one and is refused. Same mechanism, opposite answers, which is what the two cases
+ // deserve.
+ lifecycle++;
+ enabled = true;
+ applicationHasChosen = true;
+ // Asking for what the port held: a continuation declined before this call is exactly what
+ // enabling is meant to pick up.
+ installCallback(true);
+ // And what THIS class held. An arrival that reached the callback before the application
+ // enabled continuity is parked here rather than left with the port -- see Callback.decide
+ // -- so enabling has to look. On the next turn, because dispatch() runs application
+ // listeners and enable() is not the place to do that.
+ if (parked != null || !shelved.isEmpty()) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ drainPendingOffers();
+ }
+ });
+ }
+ // And the RELAY, which is the third place a session's work can be waiting.
+ //
+ // setRelay() installs the transport and does not read it, and clear() and disable() end
+ // the session and drop any fetch in flight -- so the enable() that comes with a login had
+ // the relay object still installed and nothing asking it anything. The account that just
+ // signed in then did not see its own state from another device until the application
+ // happened to call pollRelay() or the app was resumed: Android's resume poll is a
+ // different event that a login completed in the foreground never fires, and there is no
+ // automatic one on iOS at all.
+ //
+ // Unlike the disable() that clear() needs, there is nothing here to leave to the
+ // application. A new session beginning is exactly the moment to read the relay, and this
+ // is the code that knows one began. pollRelay() guards itself on a relay being installed
+ // and defers behind a publish in flight, and a second poll from an application that also
+ // asks is coalesced -- so the cost of being wrong about wanting it is nothing.
+ pollRelay();
+ }
+
+ /// Admits everything this class was holding before enable(), oldest first. On the EDT.
+ ///
+ /// The SHELF as well as the slot. Two devices can each reach the callback before the
+ /// application enables continuity -- a synced-store listener installs that seam without
+ /// enabling anything -- and the second displaces the first into the shelf. Admitting only the
+ /// slot left that first arrival in a state nothing resolves: never dispatched, so its
+ /// listeners and provider never saw it, and reachable only if the application happened to call
+ /// getRestorableState() by hand.
+ ///
+ /// Worse since the shelf started holding relay publication, which is a hold this same change
+ /// introduced: an arrival nothing is going to dispatch was withholding every checkpoint from
+ /// this device for the rest of the process.
+ ///
+ /// Through admit(), not dispatch(). A port is allowed to retain the same continuation it
+ /// declined, so both it and this class can be holding one copy each -- and installing the seam
+ /// re-offers the port's while this drains ours. admission is where (origin, sequence)
+ /// deduplication lives, so it is what makes the second copy a no-op; dispatching straight past
+ /// it ran the listeners and the provider twice on one arrival.
+ ///
+ /// Snapshotted and cleared BEFORE any of it is admitted, because admit() lands back in
+ /// placeOnOffer() for anything a listener defers -- so draining a live collection would either
+ /// re-admit what was just put back or lose it.
+ private static void drainPendingOffers() {
+ if (!enabled) {
+ return;
+ }
+ AppState[] pending = takeAllPendingOffers();
+ // takeAllPendingOffers() has already put them oldest first. Foreach rather than an index:
+ // PMD's ForLoopCanBeForeach is on the project's forbidden list, and a plain walk of an
+ // array is exactly what it is about.
+ int drainingIn = lifecycle;
+ for (AppState state : pending) {
+ if (!stillTheSameSession(drainingIn)) {
+ // A listener that ran during this drain is allowed to end the session, and what
+ // follows must not then be admitted into the one that replaced it.
+ //
+ // This asked only whether continuity was still ENABLED, which sees a disable() and
+ // not a clear() -- and clear() is the logout, the case that matters most, which
+ // deliberately leaves an enabled framework enabled. So the generation is the thing
+ // to compare, and enabled is the other half of the same question.
+ return;
+ }
+ admit(state);
+ }
+ }
+
+ /// Whether the session a drain started in is still the one running.
+ ///
+ /// Both halves, because either can end it: disable() clears `enabled` and clear() bumps the
+ /// generation while deliberately leaving it set. A drain hands each state to application code,
+ /// and that code is entitled to do either.
+ ///
+ /// The generation arrives as a PARAMETER rather than being compared against a local copy of
+ /// the field. That is the shape the inbound callback already uses -- decide(state, arrivedIn)
+ /// -- and it is also the one the analyzers accept: comparing a field to a local snapshot of
+ /// itself reads as a self-comparison to SpotBugs, which cannot see that dispatch() reaches
+ /// application code that may change it.
+ ///
+ /// #### Parameters
+ ///
+ /// - `generation`: the value of `lifecycle` when the drain began
+ ///
+ /// #### Returns
+ ///
+ /// true while the drain may go on
+ private static boolean stillTheSameSession(int generation) {
+ return enabled && lifecycle == generation;
+ }
+
+ /// Empties BOTH holders and returns what they held, oldest first. On the EDT.
+ ///
+ /// Snapshotted and cleared before the caller does anything with the result, because both
+ /// dispatch() and admit() land back in placeOnOffer() for whatever a listener defers -- so
+ /// draining a live collection would either re-take what was just put back or lose it.
+ ///
+ /// Oldest first by the ORIGIN's clock, which is the only ordering two devices share: a
+ /// sequence is a per-device counter and comparing one against another's says nothing. That
+ /// leaves the newest as the one on offer once they have all been through.
+ ///
+ /// Selection sort rather than Collections.sort, because this is the core API surface -- what
+ /// exists is what vm/JavaAPI and Ports/CLDC11 both define -- and the count is the number of
+ /// the user's devices.
+ private static AppState[] takeAllPendingOffers() {
+ AppState[] pending = new AppState[shelved.size() + (parked == null ? 0 : 1)];
+ int count = 0;
+ if (parked != null) {
+ pending[count++] = parked;
+ }
+ Iterator i = shelved.values().iterator();
+ while (i.hasNext()) {
+ pending[count++] = i.next();
+ }
+ parked = null;
+ shelved.clear();
+ for (int a = 0; a < count - 1; a++) {
+ int oldest = a;
+ for (int b = a + 1; b < count; b++) {
+ if (pending[b].getTimestamp() < pending[oldest].getTimestamp()) {
+ oldest = b;
+ }
+ }
+ AppState swap = pending[a];
+ pending[a] = pending[oldest];
+ pending[oldest] = swap;
+ }
+ return pending;
+ }
+
+ /// Hands the port a callback.
+ ///
+ /// Installing one is ALSO how a port is asked to re-offer a continuation it declined earlier
+ /// and is holding -- our iOS port does exactly that, and it is what recovers a Handoff that
+ /// cold-launched the app before anything was listening. So the two things cannot be fully
+ /// separated, and pretending otherwise stranded that arrival: with a strict install-once
+ /// guard, a sync-only listener installed the seam, the arrival was declined and held, and the
+ /// enable() that followed skipped the install and never asked for it again.
+ ///
+ /// `askForHeld` is the distinction that matters. False is "make sure a seam exists", which is
+ /// what every SyncedStore listener wants and what must not grow with their number. True is
+ /// "and hand me anything you kept", which is worth a re-install and happens at four bounded
+ /// moments: enable(), disable(), clear(), and a bridge the port has swapped.
+ ///
+ /// Only from those callers, so a build that merely LINKS this class -- because something else
+ /// in the framework mentions it -- never installs a callback or touches storage.
+ private static void installCallback(boolean askForHeld) {
+ ContinuityBridge b = bridgeInternal();
+ if (b == null) {
+ return;
+ }
+ if (!askForHeld && b == callbackInstalledOn) { //NOPMD CompareObjectsWithEquals
+ // Already installed on this bridge and nothing has changed the answer, so this is a
+ // repeat that buys nothing -- the per-LISTENER case, which is the one that grew
+ // without bound and which a port registering a native observer pays for on every
+ // store change.
+ return;
+ }
+ try {
+ b.setCallback(new Callback());
+ callbackInstalledOn = b;
+ } 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() {
+ // The CHOICE is recorded whether or not there was anything to turn off. An application
+ // that enables continuity only after a login and calls disable() while logged out was
+ // leaving this flag false, so an arrival during that interval was read as a pre-enable
+ // cold-launch arrival: declined, retained by the port, and delivered by the enable()
+ // that came with the login. Saying "no" before saying anything else is still saying it.
+ applicationHasChosen = true;
+ // THIS class's held arrival, on BOTH paths and therefore before the split.
+ //
+ // Callback.decide() parks an arrival that reaches the seam before the application has
+ // chosen -- a synced-store listener installs that seam without enabling continuity -- so
+ // by the time a logged-out app says "off" there can be a copy here as well as at the
+ // port. The early return below drained only the port's, and enable() drains this slot on
+ // purpose, so the login restored a payload and routes that arrived before the application
+ // said it wanted none. The full path clears it too, a few lines down; hoisting it here
+ // covers both and says once that a disable() drops what is held.
+ parked = null;
+ shelved.clear();
+ if (!enabled) {
+ // A callback is installed even though nothing is being turned off, and it is the only
+ // way to reach an arrival that is already waiting. iOS parks a cold-launch Handoff
+ // before init() runs and hands it over when a callback is next installed -- so an
+ // application that is logged out at launch, calls disable(), and enables after the
+ // login had that parked activity drained by the enable(), when `enabled` is true
+ // again and the callback delivers it. The applicationHasChosen flag never got a look
+ // in: it is read inside the callback, and no callback existed for the port to offer
+ // the arrival to.
+ //
+ // Installed here, the port hands it over now, while `enabled` is false and the choice
+ // is recorded -- so the callback claims and drops it, which is what disable() says
+ // happens to arriving states. Same route as the one already taken by a state that
+ // arrives after this returns, rather than a second mechanism doing the same job.
+ //
+ // THIS class's held arrival goes with it. Callback.decide() parks an arrival that
+ // reaches the seam before the application has chosen -- a synced-store listener
+ // installs that seam without enabling continuity -- so by the time a logged-out app
+ // says "off" there can be a copy here as well as at the port. Draining only the
+ // port's left this one in the slot, and enable() drains that slot on purpose, so the
+ // login restored a payload and routes that arrived before the application said it
+ // wanted none. The path below clears it as part of ending the session; this one
+ // returns before reaching that, which is the whole of the difference between them.
+ installCallback(true);
+ return;
+ }
+ // Sampled with the bump, not read later: see formAtSessionEnd. And only while a restore
+ // is in flight, which is the only thing that ever reads it -- see the gate's own comment.
+ formAtSessionEnd = applyingRestore && Display.isInitialized()
+ ? Display.getInstance().getCurrent() : null;
+ lifecycle++;
+ enabled = false;
+ dirty = false;
+ parked = null;
+ shelved.clear();
+ // The relay session ends too. A checkpoint whose publish was deferred behind a fetch is
+ // still sitting in the slot, and pollFinished() starts a publisher when that fetch lands
+ // -- so without this a state queued before disable() went out on the wire after it had
+ // returned, which is exactly what "checkpoints stop" is supposed to mean. clear() has
+ // always done this; disable() is a weaker promise about the same machinery, not a
+ // different one.
+ endRelaySession();
+ 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) {
+ // A different endpoint is a different destination for anything queued for the old one and
+ // a different source for a fetch already in flight. Without this a state retained after a
+ // failed send was published to the REPLACEMENT endpoint -- an application's data sent
+ // somewhere it was never handed to -- and a poll started against the relay the app has
+ // just removed could still deliver its answer afterwards.
+ endRelaySession();
+ relay = r;
+ if (r != null) {
+ // Whether THIS call is the one that starts the session decides who does the reading.
+ //
+ // enable() returns early when continuity is already on, and it polls as the last
+ // thing it does when it is not -- so an unconditional poll here made the first
+ // installation read twice: the second call found `polling` already true, set
+ // pollAgain, and pollFinished() then issued a second GET. Two sequential reads for
+ // one setup, and if the relay's document changed between them the application was
+ // handed two different snapshots and navigated twice.
+ boolean wasEnabled = enabled;
+ enable();
+ if (wasEnabled) {
+ // Already running, so enable() did nothing at all. A new endpoint is a new source
+ // and nothing else is going to ask it.
+ 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
+ ///
+ /// #### Throws
+ ///
+ /// - `java.lang.IllegalArgumentException`: when the label is longer than a stored checkpoint
+ /// can hold
+ public static void setTitle(String t) {
+ if (t != null) {
+ // HERE, synchronously, because the alternative is not "it fails later" but "nothing
+ // works again". capture() builds the AppState through the validating setter, so an
+ // oversized label threw out of the next checkpoint -- which nothing catches: `dirty`
+ // is assigned after capture() returns, so it stayed set, and every later navigation
+ // retried the same failing capture. Nothing was stored or published again, and the
+ // application was never told why.
+ StateCodec.requireWritable(t, "title");
+ }
+ 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() {
+ String id = deviceId;
+ if (id != null) {
+ return id;
+ }
+ // Read rather than cached here. enable() is what installs the field, and this is
+ // reachable before that -- a continuation can arrive on a cold launch -- so the
+ // pre-enable path answers from storage instead. loadDeviceId() persists the UUID it
+ // mints, so asking twice gives the same answer; there is no second identity to create.
+ return loadDeviceId();
+ }
+
+ // ------------------------------------------------------------------
+ // Saving
+ // ------------------------------------------------------------------
+
+ /// Internal. Called by `com.codename1.router.Navigation` between the route factories of a
+ /// restore, and again before it shows the rebuilt screen. True once a factory has ended the
+ /// session -- so the rebuild stops instead of running every remaining factory against an
+ /// account that has just signed out.
+ ///
+ /// The lifecycle check in `restore()` runs only after `restoreStack()` has returned, which is
+ /// far too late for this: by then those factories have constructed their forms, and whatever
+ /// they queried or wrote for the signed-out account is done. Emptying the stack afterwards
+ /// undoes none of it.
+ ///
+ /// A direct call for the reason `routeStackChanged()` gives: it answers false immediately
+ /// unless a restore is actually in progress, and a listener registry here would be public API
+ /// earned by one internal caller.
+ ///
+ /// #### Returns
+ ///
+ /// true when the session that the restore in progress began in has ended
+ public static boolean restoreSessionEnded() {
+ return applyingRestore && lifecycle != lifecycleAtRestoreStart;
+ }
+
+ /// The lifecycle generation the restore in progress began in. Only meaningful while
+ /// `applyingRestore` is true.
+ private static int lifecycleAtRestoreStart;
+
+ /// 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;
+ }
+ if (applyingRestore) {
+ // See restore(). The stack is being rebuilt from a state we already hold, so there is
+ // nothing new to record, and publishing it would start a restore loop between this
+ // device and the one that sent it. Not marked dirty either -- restore() persists the
+ // state it applied.
+ return;
+ }
+ if (clearingStack) {
+ // The logout emptying the stack, not the user going anywhere. BEFORE the dirty flag,
+ // not after it: marking it and then returning left a checkpoint owed, which a flush
+ // queued earlier -- or Android's next suspend -- then performed, rebuilding the
+ // deleted checkpoint from the still-installed provider and publishing the signed-out
+ // account's payload after logout had removed it.
+ return;
+ }
+ dirty = true;
+ if (!Display.isInitialized() || flushScheduled) {
+ return;
+ }
+ // Coalesced: a burst of navigations in one cycle costs a single write.
+ flushScheduled = true;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ flushScheduled = false;
+ if (isCheckpointPending()) {
+ 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;
+ }
+ // TWO answers, because the two failures need opposite handling and one flag gave them the
+ // same. A payload that could not be read still leaves routes worth saving and worth
+ // advertising; a sequence that did not reach the disk makes PUBLISHING the harmful part,
+ // because the receiving devices record that number durably and this one will hand it out
+ // again after a restart -- so every checkpoint it then sends is refused as already seen
+ // until the counter climbs past it. Folding them together published exactly that.
+ boolean[] payloadFailed = new boolean[1];
+ boolean[] sequenceFailed = new boolean[1];
+ AppState state = capture(payloadFailed, sequenceFailed);
+ if (state == null) {
+ return;
+ }
+ if (payloadFailed[0]) {
+ // The last payload is CARRIED FORWARD rather than replaced by nothing. Writing an
+ // empty state over a stored draft loses it, for a read that may well succeed next
+ // time; skipping the write instead loses the routes, which are current and real.
+ // Carrying forward gives up neither -- the checkpoint keeps the newest routes and the
+ // newest payload that ever read cleanly.
+ AppState previous = readStored();
+ if (previous != null && !previous.payloadRef().isEmpty()) {
+ state.setPayloadUnchecked(previous.payloadRef());
+ }
+ }
+ // Owed while anything about this capture was not durable, so a later suspend retries it.
+ dirty = payloadFailed[0] || sequenceFailed[0];
+ if (!persist(state)) {
+ dirty = true;
+ }
+ if (sequenceFailed[0]) {
+ // Stored locally and told to nobody. The local copy is still worth having -- this
+ // device does not deduplicate against itself -- but a sequence that cannot be proved
+ // durable must not reach another device, because a receiver's mark outlives the
+ // counter that produced it.
+ return;
+ }
+ publishContinuation(state);
+ publishToRelay(state);
+ }
+
+ /// Internal. Whether a checkpoint is owed -- something changed since the last one was
+ /// written.
+ ///
+ /// Exists so a port with a suspend callback can skip the event-thread round trip entirely in
+ /// the common case, where the write-through already happened as the user navigated.
+ ///
+ /// #### Returns
+ ///
+ /// true when `checkpoint()` would write something new
+ public static boolean isCheckpointPending() {
+ return enabled && dirty;
+ }
+
+ /// Builds a state from the route stack and the provider. Useful for sending one somewhere of
+ /// your own.
+ ///
+ /// The state itself is not stored -- only `checkpoint()` does that -- but the sequence counter
+ /// it allocates is remembered, so states keep a rising order across a relaunch even for an
+ /// application that never checkpoints. When that counter cannot be written this returns null,
+ /// because a state carrying a number this device will hand out again is unsafe to send.
+ ///
+ /// #### Returns
+ ///
+ /// the current state, or null when the framework is not enabled or the sequence counter
+ /// could not be stored -- see below for why the second one is refused rather than returned
+ ///
+ /// #### Throws
+ ///
+ /// - `IllegalArgumentException`: when the provider returned an unrepresentable payload
+ public static AppState capture() {
+ boolean[] payloadFailed = new boolean[1];
+ boolean[] sequenceFailed = new boolean[1];
+ AppState state = capture(payloadFailed, sequenceFailed);
+ if (sequenceFailed[0]) {
+ // Refused rather than returned. The one documented use of this method is to hand the
+ // state to a transport of the application's own, and that is exactly the act a
+ // non-durable sequence makes harmful: the receiver records the number against its
+ // durable high-water mark, this device reloads a LOWER counter after a restart and
+ // issues the same number again, and every state it sends from then on is discarded
+ // as already seen -- silently, on both sides, until the counter climbs past it.
+ //
+ // checkpoint() answers the same failure by storing locally and staying pending, and
+ // this is the same rule for the path where the caller IS the publisher: it cannot
+ // make that judgement, because nothing on the state says the number is safe.
+ //
+ // Best effort still holds for the PAYLOAD -- a provider that threw leaves real routes
+ // worth sending, which is why the two failures cannot share a flag. rememberSequence()
+ // has already logged the storage failure, so the null is not the only trace.
+ return null;
+ }
+ return state;
+ }
+
+ /// As above, reporting the two ways a capture can come up short -- SEPARATELY, because the
+ /// caller has to treat them differently.
+ ///
+ /// `payloadFailed` means the provider threw and this state has no payload of its own. The
+ /// routes are still real and the state is still worth storing and advertising.
+ ///
+ /// `sequenceFailed` means the counter did not reach the disk. That one makes publishing the
+ /// harmful act: a receiver records the sequence durably, this device hands the same number
+ /// out again after a restart, and every later checkpoint is refused as already seen.
+ ///
+ /// They were one flag, twice: first called providerFailed and then given a second meaning,
+ /// then renamed to captureFailed to match -- which papered over the fact that the two answers
+ /// call for opposite handling rather than a better name.
+ private static AppState capture(boolean[] payloadFailed, boolean[] sequenceFailed) {
+ if (!enabled) {
+ return null;
+ }
+ int lifecycleAtCapture = lifecycle;
+ StateProvider p = provider;
+ AppState state = new AppState();
+ // usableRoutes(), the filter written for the INBOUND path, applied here for the same
+ // reason at the other end. Its own comment describes this exact failure -- a route past
+ // the stored-string limit throws out of capture(), leaves the pending flag set, and every
+ // later navigation retries the same throw while nothing is persisted or published -- and
+ // it prevented it only for routes that arrived from another device. A long deep link the
+ // application navigated to itself reaches the stack the same way and ends every
+ // checkpoint this process would ever make.
+ //
+ // Not stoppable earlier: Navigation is a general routing API and must not refuse a path
+ // because continuity could not store it. Dropping the route and saying so once is the
+ // lesser loss.
+ state.setRoutes(usableRoutes(currentRoutes()));
+ 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 state.
+ //
+ // Reported, though, because "absent" and "could not be gathered" are different
+ // answers and the caller decides what to do with them. A payload-only app whose
+ // provider threw would otherwise checkpoint an EMPTY state over the last good
+ // one, publish it, and clear the pending flag -- so a process death lost the
+ // draft that was safely stored a moment earlier, because of a failure that may
+ // well be transient.
+ Log.e(t);
+ payloadFailed[0] = true;
+ }
+ if (lifecycle != lifecycleAtCapture) {
+ // Asked AFTER the try/catch, so both exits answer it. The check used to sit on
+ // the normal-return path only, so a provider that ended the session and THEN
+ // threw -- cleanup failing after it noticed an expired account -- carried on
+ // here and had its state persisted and advertised for the account that had just
+ // signed out.
+ sequenceFailed[0] = true;
+ return null;
+ }
+ 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();
+ long seq = sequence;
+ String label = title;
+ // Persisted HERE rather than in persist(), which only checkpoint() reaches. capture() is
+ // public and documented for sending a state through the application's own transport, and
+ // a counter that only advanced durably on the checkpoint path restarted lower after a
+ // relaunch -- so a receiver still holding the old high-water mark in lastSeen silently
+ // ignored every state until the counter caught up.
+ if (!rememberSequence(seq)) {
+ sequenceFailed[0] = true;
+ }
+ state.setDeviceId(getDeviceId())
+ .setSequence(seq)
+ .setTimestamp(System.currentTimeMillis())
+ .setTitle(label);
+ 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() {
+ // Aged like a stored one, and the shelf with it. An arrival that could not be shown yet
+ // -- during a cold launch, say -- has time passing while it waits, so exempting it would
+ // have let exactly the expiry the application configured slip through on the one path
+ // where the delay is longest. nextOffer() applies that and promotes a shelved arrival
+ // once the slot empties; when everything on offer has expired we fall through to the
+ // stored checkpoint below, which is ordinary with automatic restore off and the user
+ // still navigating.
+ AppState waiting = nextOffer();
+ if (waiting != null) {
+ return waiting;
+ }
+ AppState stored = readStored();
+ if (stored == null || isTooOld(stored)) {
+ return null;
+ }
+ return stored;
+ }
+
+ /// Whether `getMaxAge()` has passed since a state was produced.
+ ///
+ /// A state with no timestamp is never too old: it came from a build that did not set one, and
+ /// discarding it would be reading "unknown" as "expired".
+ private static boolean isTooOld(AppState state) {
+ if (maxAge <= 0) {
+ // No expiry configured, so nothing expires.
+ return false;
+ }
+ long ts = state.getTimestamp();
+ if (ts == 0) {
+ // The documented "this state carries no time". Nothing to measure against, so an
+ // application that built its own state by hand is not caught out by an age it never
+ // set a clock for.
+ return false;
+ }
+ if (ts < 0) {
+ // Malformed, and refused HERE rather than only at the wire. The codec rejects a
+ // negative ts in a relay DOCUMENT, but a custom StateRelay.fetch() returns an AppState
+ // directly and never goes through it -- and so does an application calling restore()
+ // with one it built. Every one of those paths ends up in this predicate, which is why
+ // the answer belongs here.
+ //
+ // Too old rather than exempt. Reading it as "carries no time" made a state that cannot
+ // prove its freshness immortal under exactly the maxAge the application configured to
+ // stop that -- an expired checkout restorable for the life of the install. A value
+ // that means nothing should not outrank an expiry that means something.
+ return true;
+ }
+ return System.currentTimeMillis() - ts > maxAge;
+ }
+
+ /// 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;
+ }
+ // Cleared only AFTER the restore has actually happened. Clearing first threw away the
+ // only copy, and because dispatch had already written the sender's durable mark, a relay
+ // retry was rejected after the next launch too -- a state that was never restored then
+ // could not be restored at all, which is the one outcome this feature exists to prevent.
+ boolean[] failed = new boolean[1];
+ boolean shown = restore(state, failed);
+ if (failed[0]) {
+ // An attempt that FAILED keeps the slot. restore(state) deliberately does not
+ // acknowledge one -- a provider that threw is usually transient, so the state stays
+ // on the relay for a launch that can use it -- and clearing here undid exactly that:
+ // admit() has already put the sequence in the live map, so nothing offers the state
+ // again this run, and releasing the publication lets a checkpoint overwrite the
+ // relay's only copy. The retry it was being kept for then has nothing to retry.
+ //
+ // ON OFFER, which is what keeps a state that came from STORAGE rather than from the
+ // slot. dispatch() already does this for an arrival whose restore failed; the
+ // application-driven path did not, so a cold start whose provider threw -- a
+ // dependency not up yet, which is the transient this whole failure branch exists for
+ // -- left the on-device checkpoint as the only copy, and the next navigation
+ // checkpointed the fallback screen over it. The draft the user was promised is then
+ // gone, at exactly the moment "restore, or else begin" was meant to protect it.
+ //
+ // A no-op when the state came from the slot: placeOnOffer() returns immediately when
+ // it is asked to replace something with itself.
+ //
+ // It does hold relay publication until the application resolves the state, by
+ // retrying or by acknowledging it. That is the same hold a failed arrival already
+ // takes and for the same reason -- what is on the relay is worth more than what this
+ // device would write over it while it cannot even load its own payload.
+ placeOnOffer(state);
+ return shown;
+ }
+ // Released because the state was APPLIED, not because a form appeared. Gating this on
+ // `shown` kept a payload-only arrival parked for ever: restore(state) hands the payload
+ // to the provider and returns false, so every later call re-applied the same state.
+ //
+ // `failed` is the distinction `shown` cannot make. False means both "there was no form to
+ // show, and that is success" and "this did not work", which need opposite handling here --
+ // the same conflation that put two flags in capture() and in checkpoint().
+ boolean shelfSettled = settleShelved(state);
+ if (supersedesParked(state)) {
+ parked = null;
+ // The slot is what holds a publication back; the decision has been made, so anything
+ // waiting on it can go out now.
+ startPublisher();
+ } else if (shelfSettled) {
+ // The SHELF held it too, and settling a shelved arrival without touching the slot is
+ // an ordinary path: a listener keeps its own reference to what it deferred and
+ // acknowledges that, rather than promoting it first. startPublisher() rechecks
+ // whatever else is still on offer, so this cannot release a hold that is still owed.
+ startPublisher();
+ }
+ return shown;
+ }
+
+ /// Records that the application has handled `state` itself, so it is not offered again.
+ ///
+ /// For the pattern `ContinuityListener` documents: do the work yourself and return false. That
+ /// path never reaches restore(), so nothing recorded the acknowledgement durably -- the
+ /// sequence stayed in this process only, and after a relaunch the relay's unchanged document
+ /// was accepted again and the listener repeated its side effects, against the act-once
+ /// guarantee.
+ ///
+ /// Deliberately NOT inferred from a false return. False also means "keep it, I will prompt and
+ /// call restore() when the user accepts", and marking that handled immediately would lose the
+ /// state if the process died before they answered -- which is the same data loss as marking a
+ /// parked state. The two intentions are different, so the application says which it means.
+ ///
+ /// #### Parameters
+ ///
+ /// - `state`: the state that has been dealt with
+ public static void acknowledge(AppState state) {
+ if (state == null) {
+ return;
+ }
+ if (isFromAnEndedSession(state)) {
+ // The same hold-it-and-come-back-later pattern restore() already refuses, arriving
+ // through the other door. Marking it would recreate a durable high-water mark for the
+ // signed-out account, so a state the NEXT account sends from that same device with a
+ // lower sequence is discarded as already handled -- and the stale acknowledgement
+ // would release a parked state belonging to an origin this session never heard from.
+ return;
+ }
+ noteActedOn(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(final AppState state) {
+ return restore(state, new boolean[1]);
+ }
+
+ /// Whether this state was DELIVERED in a session that has since ended.
+ ///
+ /// The only way an application is holding one is the documented prompt-then-come-back
+ /// pattern -- a listener returns false, keeps the state, and calls restore() or
+ /// acknowledge() when the user answers -- and clear() cannot reach into the application to
+ /// take the object away. So both doors ask.
+ ///
+ /// A state the application BUILT, or one read back from storage, carries -1 and is
+ /// unaffected: this is about the framework's own delivery outliving its session, not about
+ /// restricting what an application may ask for.
+ private static boolean isFromAnEndedSession(AppState state) {
+ return state.deliveredGeneration() >= 0 && state.deliveredGeneration() != lifecycle;
+ }
+
+ /// As above, also reporting whether the attempt FAILED as opposed to having nothing to do.
+ ///
+ /// The public boolean answers "is a form showing", which is what a caller needs to decide
+ /// whether to start its own screen. It cannot also say whether the restore worked: a
+ /// payload-only state applies everything it has and still returns false. The parked slot has
+ /// to tell those apart, because releasing it is what allows the relay's copy to be replaced.
+ private static boolean restore(final AppState state, boolean[] outFailed) {
+ if (state == null) {
+ return false;
+ }
+ if (isTooOld(state)) {
+ // Rechecked HERE, where the application hands one back. dispatch() and
+ // getRestorableState() both ask, and neither is the last word: the documented flow is
+ // that a listener returns false, puts a prompt in front of the user, and calls
+ // restore(state) when they accept -- and the deciding is exactly the time that
+ // passes. A state fresh when it was offered can be stale by the time it is taken, and
+ // an expired checkout or booking hold is precisely what maxAge exists to refuse.
+ //
+ // Discarded the way getRestorableState() discards one, rather than left on offer to
+ // be handed back again: the slot is released and the publisher let go, because the
+ // hold existed for a state that is now never going to be applied.
+ if (parked == state) { //NOPMD CompareObjectsWithEquals
+ parked = null;
+ }
+ // The shelf too: a state handed back here can be one the application kept from an
+ // earlier dispatch, whose copy was displaced into the shelf while it decided. Refusing
+ // the one in its hand and leaving the twin on offer would hand it straight back.
+ settleShelved(state);
+ startPublisher();
+ return false;
+ }
+ if (isFromAnEndedSession(state)) {
+ // Delivered in a session that has since ended. The only way to be holding one of
+ // these is the documented prompt-then-restore pattern -- a listener returns false,
+ // keeps the state, and calls back when the user accepts -- and clear() cannot reach
+ // into the application to take it away. So it is refused here instead, which is the
+ // same promise every other path already keeps.
+ //
+ // A state the application BUILT, or one read back from storage, carries -1 and is
+ // unaffected: this is about the framework's own delivery outliving its session, not
+ // about restricting what an application may ask for.
+ Log.p("Continuity: refusing a state that was delivered before the session ended. "
+ + "Continuity.clear() or disable() ran while it was being held.");
+ outFailed[0] = false;
+ return false;
+ }
+ // Whether any part of this state actually reached the application. Nothing is written
+ // or acknowledged until something has: a route-only state naming routes this build no
+ // longer registers applies nothing at all, and replacing the stored checkpoint with it
+ // destroyed the user's own restorable position -- while acknowledging it stopped the
+ // relay offering it again, so the next launch found only the unusable state where a good
+ // checkpoint had been. Left alone, the old checkpoint still restores and the relay may
+ // offer this one to a build that understands it.
+ boolean applied = false;
+ // Separate from `applied`, because "there was nothing to do" and "I tried and could not"
+ // need opposite answers and one flag cannot say both. A provider that throws is the
+ // second: it can happen transiently on a cold launch, when a dependency it needs is not
+ // up yet, and treating it as "nothing to do" marked the state handled with none of its
+ // payload applied and nothing stored -- so the relay's remaining copy was refused after
+ // the next launch and the state was gone.
+ boolean failed = false;
+ int lifecycleAtRestore = lifecycle;
+ StateProvider p = provider;
+ if (p != null) {
+ try {
+ p.restoreState(state.getPayload());
+ // An empty payload is not an application. It is what a route-only state carries,
+ // and counting it would make the question below answer yes for every state.
+ applied = !state.payloadRef().isEmpty();
+ } catch (Throwable t) {
+ Log.e(t);
+ failed = true;
+ }
+ if (lifecycle != lifecycleAtRestore) {
+ // The provider called clear() or disable(), which is the documented answer to
+ // "this payload belongs to a signed-out account". Everything below --
+ // rebuilding routes, committing, persisting -- would act for a session that
+ // no longer exists, and would write back the state clear() has just deleted.
+ //
+ // The listener callback got this guard already; the provider is the OTHER
+ // application callback on this path and was missed. Both are places where an
+ // application is entitled to end the session, so both have to be asked
+ // afterwards whether it did.
+ //
+ // OUTSIDE the try, so the CATCH reaches it too. Sitting on the normal-return
+ // path only, it was skipped by a provider that signed out and then threw --
+ // cleanup breaking after it noticed an expired account -- and the route rebuild
+ // below ran for the session that had just ended. The later lifecycle check does
+ // undo the stack, but only after that account's route factories, form
+ // constructors and show callbacks have run and put its data on screen. The same
+ // mistake capture() had, in the method that mirrors it.
+ outFailed[0] = true;
+ return false;
+ }
+ }
+ List routes = usableRoutes(state.getRoutes());
+ if (routes.size() != state.getRoutes().size()) {
+ // The FILTERED set is what gets committed, and it is applied HERE so that every exit
+ // below carries it. usableRoutes() dropped a route this device cannot store, and only
+ // the copy handed to restoreStack() had it removed -- so commit() persisted the
+ // original, externalize() threw on the oversized string every time, and the arrival
+ // stayed parked: re-applied on every retry, with every relay publication held behind
+ // it, for ever.
+ //
+ // It sat further down and the payload-only return below reached commit() before it,
+ // so a state whose routes were ALL unusable -- a valid payload with nothing storable
+ // beside it -- still committed the originals and failed in exactly that way. That is
+ // the second time this reconciliation was applied to one path and not the other,
+ // which is why it is now the statement immediately after the filter it belongs to.
+ //
+ // Unchecked because these routes have already passed the very check that produced
+ // this list.
+ state.setRoutesUnchecked(routes);
+ }
+ if (routes.isEmpty()) {
+ // Payload-only restoration, which is what an app that does not use @Route gets. The
+ // provider has been given everything there is, and false is deliberate: it is what
+ // makes "restore, or else begin" still show a screen.
+ //
+ // A review asked for true here, on the reading that the provider shows the form and
+ // the caller then shows a second one over it. That is only true of a provider written
+ // that way, and StateProvider.restoreState tells providers not to be. True would be
+ // the worse failure of the two: a provider that only populates fields -- the
+ // documented shape -- would leave the application on no screen at all.
+ // The COMMIT's answer, not just `failed`. A checkpoint storage refused leaves no
+ // durable copy and no acknowledgement, which is the same "keep holding it" as a
+ // provider that threw -- and reading only `failed` here let the slot go anyway.
+ outFailed[0] = !commit(state, applied, failed);
+ return false;
+ }
+ // Applying a state is not the user navigating, and the difference is not cosmetic. The
+ // rebuilt stack reaches routeStackChanged(), which checkpoints, which republishes what we
+ // just received under THIS device's id and a fresh sequence. The originating device then
+ // cannot recognize its own work -- it arrives as a foreign device's state -- so it
+ // restores it and republishes in turn, and the two bounce the same stack back and forth,
+ // re-navigating the user on every poll.
+ // What is on screen BEFORE the rebuild, so an aborted restore can put it back. The stack
+ // and the display are two different things: clearStack() deliberately leaves the current
+ // form alone -- the caller decides where to go next -- so undoing the history of a
+ // cancelled restore left the signed-out account's SCREEN in front of the user.
+ com.codename1.ui.Form beforeRestore = Display.getInstance().getCurrent();
+ // The live stack BEFORE the rebuild, so application code that navigates during it can be
+ // recognised afterwards. A route factory or a show callback may redirect -- an expired
+ // detail page sending the user to a list -- and restoreStack() then returns false because
+ // it deliberately did not install its own screens over that choice.
+ //
+ // Without this, false reads as "nothing happened": a route-only arrival takes the
+ // failure branch below, is parked, holds relay publication, and is offered again after
+ // every launch to redirect again. The application DID handle it, by going somewhere else.
+ List routesBefore = currentRoutes();
+ boolean shown;
+ boolean routesThrew = false;
+ applyingRestore = true;
+ lifecycleAtRestoreStart = lifecycleAtRestore;
+ try {
+ shown = Navigation.restoreStack(routes);
+ } catch (Throwable t) {
+ Log.e(t);
+ shown = false;
+ routesThrew = true;
+ } finally {
+ applyingRestore = false;
+ }
+ if (lifecycle != lifecycleAtRestore) {
+ // Rebuilding a route stack RUNS APPLICATION CODE -- the route factory, the form's
+ // constructor, whatever its show callback does -- and any of it may discover that the
+ // session is over and call clear() or disable(). Committing after that repopulates
+ // both the navigation stack and the stored checkpoint with the signed-out account's
+ // state.
+ //
+ // The fourth site on this path, and the one the last round's enumeration missed: it
+ // listed the callbacks the framework invokes DIRECTLY -- the listener, restoreState,
+ // saveState -- and route dispatch reaches application code indirectly, through
+ // Navigation. Indirect is the same risk; the enumeration was of the wrong thing.
+ //
+ // The STACK is emptied again as well, which refusing to commit does not do on its
+ // own: restoreStack() had already rebuilt it before returning here, so the signed-out
+ // account's screens were back in the history even with nothing written to storage.
+ // Suppressed while doing it, or the emptying schedules a checkpoint of its own and
+ // recreates exactly what the logout removed.
+ //
+ // ONLY while the stack is still the restoration's own. A callback that ends the
+ // session and then goes somewhere -- clear() and then navigate("/login"), which is
+ // the ordinary shape of a logout discovered mid-restore -- has already replaced it,
+ // and emptying it then removed the login entry too: the display guard below kept the
+ // login FORM, so getCurrent() showed it while Navigation.getCurrent() was null and
+ // back() had nothing. disable() during a restore did worse, since it is not a logout
+ // and the pre-restore history was destroyed for it.
+ //
+ // Same rule as the two rollbacks in Navigation: undo what this restore installed, and
+ // leave what application code chose afterwards.
+ // Unchanged since before the rebuild means this restore installed NOTHING yet -- a
+ // route factory that ended the session on its first call, before restoreStack() put
+ // anything in place. The live stack is then the pre-restore history, and clearing it
+ // destroys navigation the user had before any of this started. disable() is not a
+ // logout, so there is nothing here that licenses throwing that away.
+ //
+ // The subsequence test alone could not see it: a pre-restore stack can coincide with
+ // a prefix of what was requested -- live /home against a requested /home,/detail is
+ // the ordinary case, not a contrived one -- and it read as restoration-owned. Asking
+ // whether anything changed first is what separates "the restore installed a subset"
+ // from "the restore installed nothing and this was already here".
+ List live = currentRoutes();
+ if (!live.equals(routesBefore) && isStillTheRestoredStack(live, routes)) {
+ try {
+ clearingStack = true;
+ Navigation.clearStack();
+ } catch (Throwable t) {
+ Log.e(t);
+ } finally {
+ clearingStack = false;
+ }
+ }
+ // And the SCREEN, which the stack does not speak for. restoreStack() has already
+ // shown the rebuilt form by the time control gets here -- the cancellation came from
+ // inside that showing -- so without this the user is left looking at the signed-out
+ // account's work with only its history removed.
+ //
+ // show() rather than showBack(): this is not the user navigating back, it is a
+ // restore that never happened being undone. Showing a form directly does not go
+ // through Navigation, so it records nothing and checkpoints nothing.
+ try {
+ com.codename1.ui.Form now = Display.getInstance().getCurrent();
+ // `shown` FIRST, and it closes the case the helper below used to get wrong.
+ // The restore only ever put a screen up when restoreStack() returned true; false
+ // means it installed nothing -- it aborted because a factory ended the session,
+ // found nothing to rebuild, or had show() throw and undid its own screen already.
+ // With nothing of the restore's on display there is nothing to take down, so
+ // anything showing that is not what we started on was put there by the
+ // application: a route factory that signed out and opened its own login form is
+ // the case, and it used to have that form replaced by the signed-out account's
+ // screen.
+ if (shown && beforeRestore != null
+ && beforeRestore != now //NOPMD CompareObjectsWithEquals
+ && !applicationChoseTheScreen(beforeRestore, now)) {
+ beforeRestore.show();
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ } finally {
+ // The comparison is over, so the form goes. Holding it any longer keeps the
+ // previous account's whole component tree reachable through a static for the rest
+ // of the process, and nothing is going to ask about it again.
+ formAtSessionEnd = null;
+ }
+ outFailed[0] = true;
+ return false;
+ }
+ if (routesThrew) {
+ // A THROW, which is a different thing from routes that would not rebuild, and the two
+ // were collapsed here. The reasoning below is about the orderly case: this build no
+ // longer registers those routes, they will not start working on the next launch, and
+ // the payload already worked on this one. A throw says nothing of the kind -- it is
+ // the same transient breakage a provider that throws gets, a dependency not up yet on
+ // a cold launch, which this method already treats as retryable.
+ //
+ // Independent of `applied`, which is what let it through: with the payload taken the
+ // branch below did not fire, so a state whose route was never shown was persisted and
+ // ACKNOWLEDGED -- the relay's only other copy released while the user is not on the
+ // restored screen, and the next navigation overwriting both. Restoring twice is a
+ // smaller harm than losing the work.
+ failed = true;
+ } else if (!shown && !applied && currentRoutes().equals(routesBefore)) {
+ // Routes were named, none could be rebuilt, and nothing else in the state applied
+ // either -- an attempt that failed outright, so it stays on the relay for a launch
+ // that can use it.
+ //
+ // Only when nothing else applied. A payload the provider took is real work, already
+ // in the application, and discarding it because the ROUTES are stale threw it away
+ // twice over: never written to the local checkpoint, so a cold start lost it, and
+ // never acknowledged, so the relay offered the same half-usable state after every
+ // restart -- re-applying the payload and failing the same routes each time. A route
+ // this build no longer registers will not start working on the next launch; the
+ // payload already worked on this one.
+ failed = true;
+ }
+ // Handled ALSO when the application navigated during the rebuild. It chose where to go
+ // because of this arrival, which is as much an answer as showing the restored screen
+ // would have been -- and leaving it unsettled parks it, holds publication, and offers it
+ // again after every launch.
+ outFailed[0] = !commit(state,
+ applied || shown || !currentRoutes().equals(routesBefore), failed);
+ // What the application did DURING the rebuild, which nothing else records.
+ //
+ // routeStackChanged() returns early while applyingRestore is set -- it has to, or the
+ // rebuild checkpoints and republishes the state it is applying, and the two devices
+ // bounce it back and forth. But the restored form's show callback is application code and
+ // may navigate: a screen that redirects to a newer one, an expired detail page sending
+ // the user to a list. Both notifications for that navigation land inside the window and
+ // are dropped, so the checkpoint records the routes that ARRIVED rather than the ones the
+ // user is on -- and a process death before the next one restores the screen the
+ // application redirected away from.
+ //
+ // AFTER commit(), and that ordering is the whole of it. commit() CLEARS the pending flag
+ // as part of settling the arrival, so asking before it set a flag that commit then wiped
+ // and the scheduled flush found nothing owed. Asked here, the flush that follows writes
+ // the stack the user actually has.
+ //
+ // Also after the lifecycle branch above, which returns: a callback that ended the session
+ // leaves the stack different from what was restored too -- clear() empties it -- and
+ // checkpointing there writes for a session that has just ended.
+ //
+ // Compared rather than assumed, so the ordinary arrival still costs no extra write.
+ if (!currentRoutes().equals(routes)) {
+ routeStackChanged();
+ }
+ return shown;
+ }
+
+ /// 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() {
+ if (relay == null || !enabled || !Display.isInitialized()) {
+ return;
+ }
+ if (publishing) {
+ // Deferred behind the POST, which is the other half of the rule startPublisher()
+ // already follows. The relay holds ONE document per user, so a publish in flight can
+ // replace the other device's state before a GET started now has read it -- and that
+ // GET then returns this device's own echo, which admit() drops as an echo should be
+ // dropped. The remote update is gone from the relay and was never seen. Read before
+ // write, in BOTH directions, is what makes that impossible.
+ pollAgain = true;
+ return;
+ }
+ if (polling) {
+ // One fetch at a time. Two overlapping GETs can return DIFFERENT documents -- a relay
+ // holds one per user and the other device may replace it between them -- and nothing
+ // downstream re-orders the answers: lastSeen is keyed by ORIGINATING device, so a
+ // response that left first and arrived second passes deduplication on its own key and
+ // puts the older screen over the newer one.
+ //
+ // Remembered rather than dropped: an application that polls on reconnect while a
+ // resume poll is still in flight is asking a real question.
+ pollAgain = true;
+ return;
+ }
+ startPoll();
+ }
+
+ /// Starts the one fetch worker. Called on the EDT; the worker touches nothing.
+ ///
+ /// NOT preceded by a publish. A relay holds one document per user, so a POST that reaches the
+ /// endpoint before this GET erases the other device's state -- and the GET then returns this
+ /// device's own echo, which deliver() drops, so the remote update is never seen at all.
+ /// Anything owed is sent when the fetch finishes, which is the only ordering that both sends
+ /// what is owed and reads what is there.
+ private static void startPoll() {
+ final StateRelay r = relay;
+ if (r == null || !Display.isInitialized()) {
+ return;
+ }
+ if (polling) {
+ // The one-fetch-at-a-time invariant, enforced where the fetch is STARTED rather than
+ // only at the call sites that remember to ask. Two overlapping GETs can return
+ // different documents -- the relay holds one per user and the other device may
+ // replace it between them -- and nothing downstream re-orders the answers, so the
+ // response that left first and arrived second puts the older screen over the newer.
+ //
+ // Remembered rather than dropped, exactly as the caller-side guard does: whoever
+ // wanted this read gets one when the outstanding fetch lands.
+ pollAgain = true;
+ return;
+ }
+ final int session = relaySession;
+ polling = true;
+ Display.getInstance().startThread(new Runnable() {
+ @Override
+ public void run() {
+ // The SAME preflight the publish worker does, and it was missing here. A worker
+ // is created on the event thread and runs later, so clear() or setRelay() can
+ // land in between: only the COMPLETION was rejected, after the read had already
+ // gone out. A custom relay that resolves authentication inside fetch() would
+ // therefore issue a request after logout, and could present the next account's
+ // credentials to the previous endpoint -- while clear() promises that only a
+ // request already on the wire survives it.
+ //
+ // Asked on the event thread because relaySession belongs to it, and blocking a
+ // worker on the EDT is the safe direction: the EDT never waits on a worker.
+ final boolean[] stillOurs = new boolean[1];
+ try {
+ Display.getInstance().callSeriallyAndWait(new Runnable() {
+ @Override
+ public void run() {
+ stillOurs[0] = session == relaySession;
+ }
+ });
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ if (!stillOurs[0]) {
+ return;
+ }
+ // Off the EDT because fetch() blocks, and touching NOTHING: the relay came in as
+ // a local and the answer goes back through the event queue.
+ AppState fetched = null;
+ boolean failed = false;
+ RELAY_CALL_SESSION.set(Integer.valueOf(session));
+ try {
+ fetched = r.fetch();
+ } catch (Throwable t) {
+ // Kept SEPARATE from "the endpoint had nothing". Collapsing a timeout or a
+ // server error into the same null told pollFinished() the read had succeeded
+ // and found an empty relay, which is what makes writing over the document
+ // safe -- so a queued checkpoint went out and replaced another device's state
+ // that this device had never managed to read.
+ Log.e(t);
+ failed = true;
+ } finally {
+ RELAY_CALL_SESSION.remove();
+ }
+ final AppState result = fetched;
+ final boolean fetchFailed = failed;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ pollFinished(result, fetchFailed, session);
+ }
+ });
+ }
+ }, "Continuity relay poll").start();
+ }
+
+ /// A fetch has come back. On the EDT, where every field below is owned.
+ private static void pollFinished(AppState fetched, boolean fetchFailed, int session) {
+ if (session != relaySession) {
+ // A clear(), a setRelay() or a reset() happened while this was in flight. The answer
+ // belongs to the endpoint or the account that has since gone away: delivering it would
+ // restore the previous account's work into the next account's session, and the flags
+ // were already reset by whoever ended the session.
+ return;
+ }
+ polling = false;
+ if (!fetchFailed && fetched != null
+ && (fetched.getDeviceId() == null || fetched.getDeviceId().length() == 0)) {
+ // A document the relay HELD but this build cannot use: an origin is the key every
+ // mark and every dedup decision is made against, so admit() can only log it and drop
+ // it. Counting the read as successful anyway is what does the damage -- it clears
+ // fetchUnread and releases a checkpoint queued during the GET, and that publish
+ // overwrites the relay's only copy of remote work nothing here could read.
+ //
+ // Treated as a FAILED read instead, which is what it is: the document stays where it
+ // is, the publisher stays held, and a sender that names itself can replace it.
+ //
+ // Here rather than in the codec, because the codec also reads states this device
+ // built -- a round trip through toMap must not start requiring an origin AppState
+ // itself does not require -- and here it covers every StateRelay rather than only the
+ // one shipped with the framework.
+ Log.p("Continuity: the relay returned a state with no device id. Every state has to "
+ + "carry the id of the device it came from, or nothing can tell it apart from "
+ + "the states already seen. Treated as a failed read.");
+ fetchFailed = true;
+ fetched = null;
+ }
+ // Recorded, because `polling` stops being true the moment this returns and the hold below
+ // would then last only until the next checkpoint -- which is not what "anything owed
+ // waits for a read that succeeds" says. The comment was making a promise the code kept
+ // for exactly one caller.
+ fetchUnread = fetchFailed;
+ boolean admitted = false;
+ if (fetched != null) {
+ admit(fetched);
+ admitted = true;
+ }
+ if (pollAgain) {
+ pollAgain = false;
+ startPoll();
+ return;
+ }
+ if (fetchFailed) {
+ // No publication on the strength of a read that did not happen. Sending the queued
+ // checkpoint would replace the relay's single document, and the whole reason that is
+ // safe after a poll is that the poll established what was there. A failed fetch
+ // establishes nothing, so anything owed waits for a read that succeeds.
+ return;
+ }
+ // Owed work goes out AFTER the fetch, never before it -- and after the fetched state has
+ // been DISPATCHED, not merely admitted.
+ //
+ // admit() only queues the dispatch for a later turn, on purpose: a second turn is what
+ // lets an older state notice it was superseded. So `parked` is still null here, and
+ // startPublisher()'s hold on a parked arrival -- the guard that stops a publish from
+ // overwriting the relay's only copy of a state nobody has dealt with yet -- had nothing
+ // to see. The worker it started never looks at `parked` again.
+ //
+ // Queued behind the dispatch rather than run beside it. Serial calls keep their order, so
+ // this runs after the turn that decides whether the arrival parks, and it then reads the
+ // answer instead of racing it.
+ if (admitted) {
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ startPublisher();
+ }
+ });
+ return;
+ }
+ startPublisher();
+ }
+
+ /// Forgets everything: the stored checkpoint, any parked arrival, the activity advertised to
+ /// the user's other devices, and anything queued for the relay.
+ ///
+ /// Belongs on your logout path, PAIRED WITH disable(), and enable() on the way back in.
+ ///
+ /// This call alone is not a logout. It forgets the account's data and deliberately leaves
+ /// continuity switched ON, because forgetting state and turning the feature off are two
+ /// different things: an application is entitled to do the first without the second -- a
+ /// "start over" that is not a sign-out -- and making this imply the other would stop
+ /// continuity dead for every app that used it that way and never called enable() again.
+ ///
+ /// The consequence, if it is not paired: a continuation that arrives while the login screen is
+ /// up reaches a framework that is still listening and still enabled. It is a valid arrival by
+ /// every test this class makes -- it came AFTER the clear, so it is not from the session that
+ /// ended -- so the signed-out account's routes and payload are restored over the login screen
+ /// and written to storage. disable() is what closes that gap; enable() at login reopens it.
+ ///
+ /// 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 -- and a queued relay publish would have gone out later under whatever credentials the
+ /// relay returned by then, which after a logout is the NEXT account's.
+ ///
+ /// One thing it cannot undo: a relay request already on the wire when this is called. Nothing
+ /// in this process can recall that. What this guarantees is that nothing follows it.
+ public static void clear() {
+ // Sampled with the bump, not read later: see formAtSessionEnd. And only while a restore
+ // is in flight, which is the only thing that ever reads it -- see the gate's own comment.
+ formAtSessionEnd = applyingRestore && Display.isInitialized()
+ ? Display.getInstance().getCurrent() : null;
+ lifecycle++;
+ parked = null;
+ shelved.clear();
+ dirty = false;
+ // The PORT's held arrival too, not only this class's parked slot. A Handoff that
+ // cold-launches a logged-out app reaches IOSContinuityCallbacks before anything has
+ // installed a callback, and it is held there -- so clearing `parked` cleared nothing that
+ // existed, and the enable() that came with the later login drained the port and restored
+ // the pre-logout payload and routes into the next account. clear() says nothing from
+ // before it survives.
+ //
+ // A one-shot rather than applicationHasChosen: clear() is a logout, not "I do not want
+ // continuity", and it deliberately leaves an enabled framework enabled. Recording a
+ // choice here would make every arrival AFTER the clear be dropped instead of held for
+ // the enable() that is about to come -- and an arrival after the clear is not from
+ // before it.
+ //
+ // The window is THIS CALL, and that is a contract rather than an accident: setCallback()
+ // requires a port to offer a held continuation before it returns. It has to be the call,
+ // because a held continuation reaches the seam by exactly the same route a brand new one
+ // does and carries nothing that tells them apart -- so the framework cannot bind the
+ // discard to the cleared session instead. A window that outlasted this call would start
+ // eating the arrivals meant for the account now signing in, which is the other half of
+ // what clear() promises and the reason it is not simply "continuity off".
+ //
+ // A port that answered later would have its pre-logout activity taken for a new one. That
+ // is a port failing its side of the contract, not a case for this code to guess at: the
+ // guess that would cover it -- drop everything for a while after a logout -- is exactly
+ // the behaviour the paragraph above rules out.
+ discardHeldArrival = true;
+ try {
+ installCallback(true);
+ } finally {
+ discardHeldArrival = false;
+ }
+ // The label goes with the work it describes. It is CONTENT, not configuration -- "Draft
+ // to Dana", "Invoice 2031", read at every checkpoint -- so leaving it behind meant the
+ // first checkpoint after a logout, a login screen or the next account's opening route,
+ // re-advertised the previous user's label to every device around them. Withdrawing the
+ // current activity below is not enough on its own: the field outlives it and the next
+ // publish puts it straight back.
+ //
+ // The configuration is deliberately left alone: the provider, the relay, autoRestore,
+ // maxAge and this device's id are how the application is wired, not what the last user
+ // was doing, and an app would have to install them all again after every logout.
+ title = null;
+ // Anything queued for the relay belonged to the account that just signed out, and a relay
+ // reads its credentials when the request runs rather than when it was queued -- so a state
+ // left here would have gone out under the NEXT account's token. The session is ended too,
+ // so a fetch already in flight is not delivered into the account that just signed in.
+ //
+ // The one thing this cannot recall is a request already on the wire. Nothing in this
+ // process can; what it can do is make sure nothing follows it.
+ endRelaySession();
+ lastSeen.clear();
+ durableSeen.clear();
+ // Forgotten beside the marks it belongs with. It records what THIS account's session
+ // dealt with, and keeping it would let a state re-delivered to the next account be
+ // treated as already handled.
+ lastCompleted = null;
+ // The durable copy as well, and DELETED rather than merely overwritten. Leaving it
+ // behind meant the marks of the account that just signed out kept suppressing the NEXT
+ // account's deliveries -- a state silently never arriving, which is harder to notice
+ // than one arriving twice.
+ //
+ // rememberSeen() alone was not enough: it writes the emptied map, and a write storage
+ // refuses leaves the whole of the previous account's marks on disk for the next launch to
+ // reload. Which devices an account synced with, and how far, is that account's data as
+ // much as its routes are -- so this gets the same treatment as the checkpoint below,
+ // rather than the weaker one it had because it happened to be written through a helper.
+ rememberSeen();
+ try {
+ if (Display.isInitialized() && Storage.getInstance().exists(PREF_SEEN)) {
+ // Blanked and CHECKED before the delete, exactly as the checkpoint below is, and
+ // for the reason written there: deleteStorageFile() returns void and the ports
+ // discard the answer they get, so a refused deletion is invisible. An unverified
+ // delete beside a verified one was the inconsistency -- if rememberSeen()'s write
+ // failed too, the signed-out account's marks stayed on disk and the next launch
+ // reloaded them, suppressing the next account's states from the same origins.
+ boolean blanked = Storage.getInstance().writeObject(PREF_SEEN, "");
+ Storage.getInstance().deleteStorageFile(PREF_SEEN);
+ if (!blanked && Storage.getInstance().exists(PREF_SEEN)) {
+ Log.p("Continuity: the delivery marks could not be removed on logout, so the "
+ + "previous account's origins may go on suppressing states after a "
+ + "restart.");
+ }
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ clearContinuation();
+ // The route history is the previous account's work as surely as the stored checkpoint is.
+ // Leaving it kept two promises broken: back() reopened the signed-out account's forms,
+ // and the next navigation checkpointed and republished a stack that still began with
+ // their routes -- so the state this method had just deleted went straight back out.
+ //
+ // Before the deletion below, and clearStack() deliberately does not notify continuity:
+ // either way round, a checkpoint here would write the emptied stack over what is being
+ // removed.
+ try {
+ // Suppressed HERE, not in Navigation. clearStack() notifies for every other caller,
+ // because forgetting the back history really is a change worth checkpointing -- it is
+ // only this one that must not, since a checkpoint would write the emptied stack
+ // straight back over the storage being deleted two lines down.
+ clearingStack = true;
+ Navigation.clearStack();
+ } catch (Throwable t) {
+ Log.e(t);
+ } finally {
+ clearingStack = false;
+ }
+ try {
+ if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) {
+ // Overwritten BEFORE it is deleted, and the overwrite is the part that is
+ // checked. deleteStorageFile() returns void, and the ports behind it discard the
+ // answer they do get -- JavaSE ignores File.delete()'s boolean, Android ignores
+ // Context.deleteFile()'s -- so a refused deletion is invisible here and leaves
+ // the signed-out account's routes and payload on disk, ready to be restored into
+ // the next login. That is the one thing logout must not do.
+ //
+ // The replacement is deliberately NOT an empty AppState: readStored() answers
+ // null for anything that is not an AppState, so a plain empty string leaves
+ // getRestorableState() null exactly as a successful delete would, while a blank
+ // state would have been offered for restoration instead.
+ boolean blanked = Storage.getInstance().writeObject(STORAGE_KEY, "");
+ Storage.getInstance().deleteStorageFile(STORAGE_KEY);
+ if (!blanked && Storage.getInstance().exists(STORAGE_KEY)) {
+ // Both attempts refused, which is the only case the user can still be hurt
+ // by. Said plainly, because the alternative is a silent logout that did not
+ // take -- and there is nothing further this can do about it.
+ Log.p("Continuity: the stored checkpoint could not be removed on logout, so "
+ + "the previous account's state may be restored after a restart.");
+ }
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ /// Ends the current relay session: nothing queued goes out, and nothing in flight comes back.
+ ///
+ /// The counter is what makes an in-flight round trip harmless without any locking. A worker
+ /// carries the session it started in, and its completion -- which runs on the EDT -- returns
+ /// early when the session has moved on, so the flags reset here stay reset.
+ private static void endRelaySession() {
+ relaySession++;
+ pendingPublish = null;
+ publishing = false;
+ polling = false;
+ pollAgain = false;
+ publishRequested = false;
+ // A new session has read nothing yet, and owes nothing either: the slot was just emptied.
+ // Carrying a failed read across would make the next checkpoint poll for no reason.
+ fetchUnread = false;
+ // Deliberately NOT cleared here. Ending a relay session says nothing about what the
+ // application has already dealt with, and forgetting it would let an arrival that was
+ // acknowledged before the session changed be parked again afterwards. reset() clears it.
+
+ }
+
+ // ------------------------------------------------------------------
+ // Internals
+ // ------------------------------------------------------------------
+
+ /// Whether the live stack is still the one this restore installed, and nothing else.
+ ///
+ /// Not equality with the REQUESTED routes, which is what this asked before and got wrong the
+ /// moment a route was skipped. restoreStack() drops a path the build no longer registers --
+ /// deliberately, that is the tolerance that lets an old checkpoint still restore what it can
+ /// -- so what it installs is a SUBSEQUENCE of what it was given, and the two are equal only
+ /// when nothing was skipped. Comparing for equality then answered "the application has
+ /// navigated" for a stack the application had not touched, and the abort left the restored
+ /// entries in Navigation: getCurrent() and back() disagreeing with what is on screen, and
+ /// those entries exposed again if continuity is re-enabled.
+ ///
+ /// A subsequence, not a subset, because order is the part that separates the two cases.
+ /// Anything the application navigated to is a path this restore did not ask for -- a login
+ /// screen, a safe list -- so it breaks the subsequence, and a stack that is still in the
+ /// restore's own order with only gaps in it is the restore's own.
+ private static boolean isStillTheRestoredStack(List live, List requested) {
+ int at = 0;
+ for (String path : live) {
+ while (at < requested.size() && !requested.get(at).equals(path)) {
+ at++;
+ }
+ if (at == requested.size()) {
+ return false;
+ }
+ at++;
+ }
+ return true;
+ }
+
+ 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 (com.codename1.router.NavigationEntry entry : stack) {
+ paths.add(entry.getPath());
+ }
+ return paths;
+ }
+
+ /// Records that a state has been dealt with, and makes it the local checkpoint when there
+ /// was something to store.
+ ///
+ /// Two separate questions, which an earlier version answered with one flag and got wrong.
+ ///
+ /// WHETHER TO STORE is `applied`: a state that changed nothing here -- a route this build no
+ /// longer registers, with no payload the application could take -- must not replace the
+ /// user's own checkpoint with something unusable.
+ ///
+ /// WHETHER TO ACKNOWLEDGE turns on a different question: did anything FAIL. The mark is
+ /// durable and stops the relay ever offering this state again, so it must never follow an
+ /// attempt that did not work -- a provider that threw, routes that could not be rebuilt, or
+ /// a write that was refused. In each of those the relay's copy is the only one left.
+ ///
+ /// "Nothing to do" is not a failure, and does mean acknowledge: an application with no
+ /// provider can never consume a payload, so there is nothing to recover and withholding the
+ /// mark only re-prompts the user on every launch for ever. That distinction is why there are
+ /// two flags and not one -- a single "did it apply" answers both questions and gets one of
+ /// them wrong whichever way it is set.
+ /// #### Returns
+ ///
+ /// true when the arrival is SETTLED -- marked handled and, where something applied, stored.
+ /// False is the caller's signal to keep holding it: void was the bug, because a checkpoint
+ /// that storage refused ended here silently and restore() then released the slot for a state
+ /// with no durable copy anywhere and no acknowledgement.
+ private static boolean commit(AppState state, boolean applied, boolean failed) {
+ if (failed) {
+ // An attempt was made and it did not work: a provider that threw, or routes that
+ // could not be rebuilt. The relay's copy has to stay on offer for a launch that can
+ // use it, so nothing is marked.
+ return false;
+ }
+ if (applied && !persist(state)) {
+ // Tried to store it and could not. The relay's copy is now the only one that exists,
+ // so it must go on being offered: acknowledging here loses the state in both
+ // directions at once.
+ return false;
+ }
+ if (applied) {
+ // A checkpoint queued before this restore describes a screen that no longer exists.
+ // A navigation while a relay GET is in flight leaves one in the slot, and sending it
+ // afterwards replaces the relay's copy of the state just accepted with the work that
+ // restore superseded. Likeliest at startup, where setRelay() polls while the initial
+ // route is still being shown.
+ //
+ // Dropped rather than recaptured. What the screen shows now IS the state that
+ // arrived, so a fresh capture would publish the fetch straight back under this
+ // device's id -- an echo, and the start of the ping-pong applyingRestore exists to
+ // prevent. Nothing goes out until the user does something new.
+ pendingPublish = null;
+ publishRequested = false;
+ // And the SCHEDULED capture, which is a fourth way the same stale screen gets out.
+ // routeStackChanged() sets `dirty` and queues a flush; that flush asks only whether a
+ // checkpoint is pending, so it ran after the restore, captured the state that had
+ // just ARRIVED under this device's identity, and published the very echo this block
+ // exists to suppress -- which the origin then accepts and restores on its next poll.
+ //
+ // Clearing it is right for the same reason the slot is cleared: whatever the user did
+ // before the restore describes a screen the restore has replaced.
+ dirty = false;
+ // The ADVERTISED activity is stale in exactly the same way, and dropping only the
+ // queued publish left half the job done. The platform activity stays current until
+ // something replaces or withdraws it, and applyingRestore suppresses the checkpoint
+ // the rebuilt route stack would otherwise have triggered -- so this device went on
+ // offering the pre-restore screen to every Apple device around it until the user
+ // happened to navigate again, and a third device could continue into a screen this
+ // one had already moved off.
+ //
+ // Withdrawn rather than re-advertised with the restored state. The device this state
+ // CAME FROM is most likely still offering it, and two devices advertising the same
+ // continuation is a worse answer than a short gap: nothing false is offered, and the
+ // user's next action advertises the truth.
+ clearContinuation();
+ }
+ noteActedOn(state);
+ return true;
+ }
+
+ /// Writes the checkpoint, and says whether it got there.
+ ///
+ /// The answer is used, not logged. Storage.writeObject returns false on a failed write -- a
+ /// full disk is the ordinary cause -- and every piece of bookkeeping around this call assumes
+ /// the state is now durable: checkpoint() clears `dirty`, and restore() marks the sender's
+ /// sequence so the relay stops offering its copy. Doing either after a failed write is how a
+ /// state is lost in both directions at once, with nothing anywhere saying so.
+ ///
+ /// #### Returns
+ ///
+ /// true when the state is in storage
+ private static boolean persist(AppState state) {
+ try {
+ return Storage.getInstance().writeObject(STORAGE_KEY, state);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Writes the sequence counter so it keeps rising across a relaunch.
+ /// Persists the sequence counter, and says whether it is actually on disk.
+ ///
+ /// Written through Storage, whose writeObject() reports failure, rather than through
+ /// Preferences, which cannot.
+ ///
+ /// An earlier attempt at this wrote through Preferences and then read the value back to
+ /// check. That verifies nothing: Preferences.set() puts the value in a static Hashtable
+ /// before calling save(), save() discards Storage.writeObject()'s result, and
+ /// Preferences.get() reads the same Hashtable -- so the read-back returns what was just put
+ /// there whether or not any of it reached the disk.
+ ///
+ /// The silence matters on this value: the counter reloads lower after a restart, and every
+ /// receiving device whose high-water mark already includes the higher number refuses this
+ /// device's states until the counter climbs past it again. States stop arriving on the other
+ /// device, with nothing logged on either.
+ ///
+ /// #### Returns
+ ///
+ /// true when the counter reached storage
+ private static boolean rememberSequence(long seq) {
+ try {
+ return Storage.getInstance().writeObject(PREF_SEQUENCE, Long.valueOf(seq));
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ 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;
+ }
+ // Said once when the payload is large, and NOT refused. The platform carries a
+ // continuation's userInfo as a small dictionary -- Apple documents it for small
+ // payloads and offers continuation streams for anything more -- but publishes no hard
+ // byte limit that fails cleanly, so an oversized one simply does not arrive on the
+ // other device. The local checkpoint and the relay are unaffected, which is what
+ // makes it hard to see: everything works except the half that crosses the room.
+ //
+ // A REFUSAL would need a number this framework cannot source. Guessing one and
+ // rejecting on it would drop states that transfer perfectly well today, which is a
+ // worse failure than the one being reported. So the size is measured with the
+ // framework's own portable stand-in and reported once per session, turning a silent
+ // failure into one with something to search for.
+ warnIfLargeForHandoff(state);
+ 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);
+ }
+ }
+
+ /// The newest state waiting to reach the relay, or null when none is.
+ ///
+ /// A slot rather than a queue: the relay's contract is that a publish REPLACES what it holds,
+ /// so an older state waiting behind a newer one has nothing to add. Coalescing here is also
+ /// what keeps a burst of checkpoints from becoming a burst of requests.
+ private static AppState pendingPublish;
+
+ /// True while the publish worker is out. One at a time, so the relay's single document is
+ /// written in the order the checkpoints happened: two workers racing the same endpoint could
+ /// leave the older state stored last, and the user's other device then fetches work they had
+ /// already moved past.
+ private static boolean publishing;
+
+ /// True while a relay fetch is out; `pollAgain` records a poll asked for during one.
+ private static boolean polling;
+
+ /// Whether the last relay read FAILED, so what the document holds is unknown.
+ ///
+ /// Publishing replaces the relay's single document, and that is only safe because a poll
+ /// established what was there. A read that timed out established nothing, so anything owed
+ /// waits -- not for the next checkpoint, which is where the protection used to end, but for a
+ /// read that succeeds.
+ private static boolean fetchUnread;
+
+ /// Bumped whenever the application ends the current session -- clear() or disable().
+ ///
+ /// Separate from relaySession, which every setRelay() moves too: installing a relay from
+ /// inside a listener is legitimate and must not abandon the dispatch that is running. This
+ /// counts only the two calls that make everything after them meaningless.
+ private static int lifecycle;
+
+ /// True while clear() is emptying the route stack, so its notification is ignored.
+ private static boolean clearingStack;
+
+ /// The parked state the publication hold has already been explained for, so it is said once.
+ private static AppState heldFor;
+
+ /// The most recently completed arrival, as an in-process fact rather than a stored one.
+ ///
+ /// One slot is the right size: the only reader asks immediately after the listeners for the
+ /// state it is about to park, so the thing that can have completed in between is that state.
+ /// It exists because the durable map is bounded by what a single stored string can hold, and
+ /// an entry can be evicted on the way in -- which is a statement about persistence, not about
+ /// whether the application has dealt with the arrival.
+ private static AppState lastCompleted;
+
+ private static boolean pollAgain;
+
+ /// True when a publisher was wanted while one was already out.
+ ///
+ /// The publisher does not retry in a loop -- one attempt per change, rather than a spin
+ /// against an endpoint that is down -- but a request that arrived DURING an attempt is a new
+ /// signal rather than a spin, and pollRelay() on reconnect is exactly that. Without it, an
+ /// application that reconnects while the failing attempt is still on the wire had its
+ /// reconnect forgotten, and the retained state waited for some later checkpoint.
+ private static boolean publishRequested;
+
+ /// Hands a state to the relay, one at a time.
+ private static void publishToRelay(AppState state) {
+ if (!Display.isInitialized() || relay == null) {
+ return;
+ }
+ // A slot rather than a queue: a publish REPLACES what the relay holds, so an older state
+ // waiting behind a newer one has nothing to add.
+ pendingPublish = state;
+ startPublisher();
+ }
+
+ /// Starts the one publish worker, if there is work and nobody is doing it. Called on the EDT.
+ ///
+ /// Separate from `publishToRelay` because a checkpoint is not the only thing that should start
+ /// one: a state retained after a failed send is sent by whatever finishes next -- a poll, or
+ /// the following checkpoint -- rather than sitting in the slot forever.
+ private static void startPublisher() {
+ if (!Display.isInitialized() || relay == null || !enabled) {
+ // `enabled` as the general invariant, beside the specific drop in disable(). Nothing
+ // may reach the relay while the framework is off, and this is the one funnel every
+ // publication passes through -- including the ones started by a worker completing
+ // after the application turned it off.
+ return;
+ }
+ // Read into a local before the test. PMD's NonThreadSafeSingleton matches the SHAPE of
+ // "null-check a static, then assign a static inside the branch" and reports it as a lazy
+ // initializer, which this is not -- and the project's gate has no per-finding allow list,
+ // so the shape is what has to change.
+ purgeShelf();
+ AppState awaitingDecision = parked;
+ if (awaitingDecision == null) {
+ // The shelf holds publication back too. A displaced arrival's only copy is in this
+ // process just as much as the slot's is -- more so, since the port has already been
+ // told the framework took it -- so publishing over the relay's document while one
+ // waits loses it for good. Peeked, not taken: promotion belongs to
+ // getRestorableState(), which is where the application asks for the next one.
+ awaitingDecision = newestShelved();
+ }
+ if (awaitingDecision != null) {
+ // A fetched state is waiting on the user and has NOT been acknowledged. The relay
+ // holds one document per user, so publishing now replaces the only copy of it that
+ // exists anywhere -- it is in memory here and nowhere else -- and a process death
+ // while the prompt is on screen loses it for good. autoRestore off, or a listener
+ // that returns false to ask first, is the ordinary way to get here.
+ //
+ // Held rather than dropped. Whatever clears the slot -- the user accepting, the
+ // application acknowledging, a logout, the state expiring -- calls back in here, and
+ // an acknowledged state is safe to overwrite because the mark is already durable.
+ // IDENTITY: the same arrival, not an equal one. Explaining the hold once per
+ // arrival is the whole point, and two distinct states that considered themselves
+ // equal are still two things the developer needs telling about.
+ if (heldFor != awaitingDecision) { //NOPMD CompareObjectsWithEquals
+ // Once per arrival, not once per checkpoint. A hold that never ends is silent
+ // otherwise: this device simply stops publishing, and the cause -- a listener
+ // that returned false to REJECT a state and never acknowledged it -- is nowhere
+ // near the symptom. The framework cannot tell that from a prompt still waiting on
+ // the user, so it says what it is doing and names the way out.
+ heldFor = awaitingDecision;
+ Log.p("Continuity: holding checkpoints because an arrival is still undecided. "
+ + "If a listener returned false to reject it, call "
+ + "Continuity.acknowledge(state) -- otherwise nothing is published from "
+ + "this device again.");
+ }
+ publishRequested = true;
+ return;
+ }
+ if (publishing) {
+ // Asked BEFORE the empty-slot check, which is the whole point of the flag. A worker
+ // that is out has already taken the state out of the slot, so the slot is empty
+ // exactly when this signal matters -- and testing it first threw the reconnect away
+ // and left the state that worker is about to fail on waiting for a later checkpoint.
+ publishRequested = true;
+ return;
+ }
+ if (pendingPublish == null) {
+ return;
+ }
+ if (polling) {
+ // A GET is outstanding. The relay holds ONE document per user, so a POST that lands
+ // before the answer overwrites the other device's state -- and the GET then reads back
+ // our own write, so the remote update is never seen. pollFinished() starts a publisher
+ // when the fetch is done.
+ return;
+ }
+ if (fetchUnread) {
+ // The last read of the relay FAILED, so what the document holds is unknown -- and
+ // writing over it is only safe because a poll established that. A timeout establishes
+ // nothing, and the protection used to end at the next checkpoint rather than at a
+ // successful read.
+ //
+ // A fresh poll rather than a refusal: an application that goes on working while the
+ // network is down must not stop publishing for the rest of the process, so the state
+ // stays owed and the read is retried. Whichever poll succeeds releases it.
+ //
+ // BELOW the polling guard, which is where this branch belongs and did not start. A
+ // recovery read is still a read, so putting it first let a second checkpoint launch
+ // one while the first was in flight -- two overlapping GETs, which is precisely what
+ // the guard above forbids and for the reason it gives.
+ publishRequested = true;
+ startPoll();
+ return;
+ }
+ final StateRelay r = relay;
+ final AppState next = pendingPublish;
+ final int session = relaySession;
+ pendingPublish = null;
+ publishing = true;
+ Display.getInstance().startThread(new Runnable() {
+ @Override
+ public void run() {
+ // Confirmed on the EVENT THREAD immediately before the request, not only when it
+ // comes back. The session check used to live in publishFinished() alone, which is
+ // after the fact: clear() or setRelay() landing between this worker being started
+ // and its first instruction still let the request go out. RestStateRelay resolves
+ // getToken() INSIDE publish(), so a quick logout and login sent the previous
+ // account's state under the NEXT account's credentials -- while clear() documents
+ // that nothing follows it.
+ //
+ // callSeriallyAndWait, not a read of relaySession from here: that field is owned
+ // by the event thread and this is not it. Blocking this worker on the EDT is
+ // fine, it is the direction that is safe -- the EDT never waits on us.
+ //
+ // What this cannot close is the instant between the answer and the call below.
+ // That is the same window clear() already documents: a request on the wire cannot
+ // be recalled. It closes the rest of it, which was the whole gap between queueing
+ // and sending.
+ final boolean[] stillOurs = new boolean[1];
+ try {
+ Display.getInstance().callSeriallyAndWait(new Runnable() {
+ @Override
+ public void run() {
+ stillOurs[0] = session == relaySession;
+ }
+ });
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ if (!stillOurs[0]) {
+ return;
+ }
+ // Off the EDT because publish() blocks, and touching NOTHING: the relay and the
+ // state came in as locals and the outcome goes back through the event queue.
+ boolean sent = true;
+ RELAY_CALL_SESSION.set(Integer.valueOf(session));
+ try {
+ r.publish(next);
+ } catch (Throwable t) {
+ Log.e(t);
+ sent = false;
+ } finally {
+ RELAY_CALL_SESSION.remove();
+ }
+ final boolean ok = sent;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ publishFinished(next, session, ok);
+ }
+ });
+ }
+ }, "Continuity relay publish").start();
+ }
+
+ /// A publish has come back. On the EDT, where every field below is owned.
+ private static void publishFinished(AppState sent, int session, boolean ok) {
+ if (session != relaySession) {
+ // The session ended while this was in flight; endRelaySession() has already reset the
+ // flags and the state this carried belongs to an account that has signed out.
+ return;
+ }
+ publishing = false;
+ if (!ok && pendingPublish == null) {
+ // Kept, not dropped -- StateRelay.publish documents that the framework holds a failed
+ // state for the next attempt, and dropping it meant the last checkpoint before the
+ // network went away never reached the other device at all. Put back only when nothing
+ // newer is queued, since a newer state supersedes it entirely.
+ pendingPublish = sent;
+ }
+ if (pollAgain) {
+ // A poll asked for while this was on the wire, and it goes FIRST: read before write
+ // is the ordering a single-document relay needs, and pollFinished() starts a
+ // publisher for whatever is still queued when it lands.
+ pollAgain = false;
+ publishRequested = false;
+ startPoll();
+ return;
+ }
+ if (!ok && !publishRequested) {
+ // Stood down rather than retried: one attempt per change, not a spin against an
+ // endpoint that is down. The next checkpoint or poll starts the next one.
+ return;
+ }
+ // Somebody asked for a publisher while this attempt was in flight -- an application
+ // calling pollRelay() on reconnect is the ordinary case. Consumed rather than looped on,
+ // so it is one extra attempt per request.
+ publishRequested = false;
+ // Drains anything queued while this was in flight, and is a no-op when nothing is.
+ startPublisher();
+ }
+
+ /// 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.
+ ///
+ /// The one method here that is called from a foreign thread -- a port hands a continuation
+ /// over on the platform's own thread -- so it does the marshalling and everything downstream
+ /// is ordinary EDT code.
+ static void deliver(final AppState state) {
+ if (state == null) {
+ return;
+ }
+ if (!Display.isInitialized()) {
+ // No event thread yet, so there is nothing to marshal to and nothing running that
+ // could be racing this. Held for the EDT that is about to start.
+ placeOnOffer(state);
+ return;
+ }
+ // The generation the arrival BELONGS to, read here rather than on the event thread,
+ // because here is where the arrival happens. A logout already queued ahead of this means
+ // admit() runs after clear() and reads the NEW generation, so every later check passes
+ // and the previous account's state is restored and persisted after the logout that
+ // promised nothing from before it survives. The second-turn dispatch had a generation and
+ // this first hop had none.
+ //
+ // Read from the platform's thread, which this method is documented to run on. An int read
+ // from another thread yields a value the event thread wrote at some point and never a
+ // future one, so the comparison can be stale-old but never stale-new: the worst it does
+ // is refuse an arrival that raced the logout exactly, which is the answer that side of
+ // the race wants anyway.
+ final int arrivedIn = lifecycle;
+ // ALWAYS queued, even when the caller is already on the EDT. Admission and dispatch are
+ // deliberately separate turns -- see admit() -- and running one caller's arrival inline
+ // while another's is queued would put them in different orders depending on who called.
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ if (arrivedIn != lifecycle) {
+ // clear() or disable() ran between the arrival and this turn.
+ return;
+ }
+ admit(state);
+ }
+ });
+ }
+
+ /// Decides whether an arrival is worth acting on, and records it. On the EDT.
+ ///
+ /// The dispatch is a SECOND turn rather than the rest of this one. Two states from the same
+ /// device can be in flight together -- a continuation and a relay poll routinely carry
+ /// different sequences -- and admitting both before either is applied is what lets the older
+ /// one notice it has been superseded and stand down. Applying inline instead walked the user
+ /// through the stale screen on the way to the fresh one.
+ private static void admit(final AppState state) {
+ if (!enabled) {
+ return;
+ }
+ String origin = state.getDeviceId();
+ if (origin == null || origin.length() == 0) {
+ // ANONYMOUS, so it cannot take part in deduplication at all: every mark is keyed by
+ // origin and sequence, and an empty origin is one key shared by every producer that
+ // forgot to set one. Admitting it looked harmless and was not -- noteActedOn() has to
+ // refuse a state with no origin, so nothing was ever marked durably and the same
+ // state restored again after every restart, while a listener acknowledging it left it
+ // parked for the life of the process with relay publication held behind it.
+ //
+ // Nothing this framework produces is anonymous; capture() always sets the id. What
+ // reaches here is a custom StateRelay handing back a state it built itself, or a
+ // relay document with no "device" member, so it is said out loud rather than dropped
+ // in silence -- the integrator is the only one who can fix it.
+ Log.p("Continuity: ignoring a state with no device id. A relay must return states "
+ + "that carry the id of the device they came from, or the same state is "
+ + "offered again after every restart.");
+ return;
+ }
+ if (getDeviceId().equals(origin)) {
+ // This device's own echo, which a relay returns as a matter of course.
+ return;
+ }
+ if (isTooOld(state)) {
+ // A relay hands back whatever it still holds, which can be days old, and an expired
+ // checkout or booking hold that auto-restored is the exact harm setMaxAge exists to
+ // prevent. Dropped before lastSeen records it, so the sequence stays free for a
+ // fresher state from the same device.
+ return;
+ }
+ Long seen = seenSequence(state.getDeviceId());
+ if (seen != null && seen.longValue() >= state.getSequence()) {
+ // Delivered twice, which happens routinely: a continuation and a relay poll can carry
+ // the same state.
+ return;
+ }
+ // Admission only: not durable until the state has actually been completed.
+ recordSeen(state.getDeviceId(), state.getSequence(), false);
+ if (state.isEmpty()) {
+ // A TOMBSTONE, not an offer. An enabled app with no routes and no payload still
+ // checkpoints, and the relay holds one document per user, so that empty state is
+ // published to overwrite whatever was there -- which is the point, it clears the
+ // other devices' stale copy. It carries a device id and a sequence, though, so the
+ // receiving side recognized it as a real arrival and ran the listeners: a
+ // "continue what you were doing?" prompt over nothing at all.
+ //
+ // The platform path has always got this right -- publishContinuation() withdraws the
+ // activity for an empty state rather than advertising one -- and only the relay path
+ // was missing the other half of it. Marked as seen above, so it is consumed rather
+ // than reconsidered, and simply not dispatched.
+ //
+ // It also SUPERSEDES anything still parked from the same origin. A tombstone is that
+ // origin saying it has nothing any more, so an older state of its own that is waiting
+ // on the user is work that no longer exists: getRestorableState() would go on offering
+ // it, and the publication hold would go on withholding this device's checkpoints
+ // behind it. Same shape as acknowledge() and expiry -- another way an arrival ends,
+ // and every one of them has to release the slot.
+ boolean shelfSettled = settleShelved(state);
+ AppState waiting = parked;
+ if (waiting != null && state.getDeviceId().equals(waiting.getDeviceId())
+ && waiting.getSequence() <= state.getSequence()) {
+ parked = null;
+ if (pollAgain) {
+ // A coalesced read is still owed. pollFinished() has already cleared
+ // `polling`, so releasing the publisher here would start the POST BEFORE that
+ // follow-up GET and then run the two together -- against a relay that holds
+ // one document, which is the situation the one-fetch-at-a-time rule exists
+ // for. The remote update the second read was going to see gets overwritten,
+ // and the read comes back with this device's own echo.
+ //
+ // Left owed instead: whoever finishes the coalesced read releases it, which
+ // is the same path every other hold uses.
+ publishRequested = true;
+ } else {
+ startPublisher();
+ }
+ } else if (shelfSettled) {
+ // The tombstone settled a SHELVED state of its own and left the slot alone. Same
+ // release, and the same deference to a coalesced read that is still owed.
+ if (pollAgain) {
+ publishRequested = true;
+ } else {
+ startPublisher();
+ }
+ }
+ // Durably, and here rather than through commit(). Consuming a tombstone is the one
+ // arrival that CANNOT fail -- there is no payload to hand over and no route to
+ // rebuild -- so there is nothing to gate the mark on, and leaving it in memory only
+ // meant the next launch had never heard of it. An older state from the same origin
+ // that was already in flight then passed admission and offered work this tombstone
+ // exists to say no longer exists.
+ recordDurable(state.getDeviceId(), state.getSequence());
+ rememberSeen();
+ return;
+ }
+ // The generation as it stands at admission. `enabled` alone could not see a disable()
+ // and an enable() that BOTH ran before this runnable did -- two queued turns are enough,
+ // and the flag is true again by the time it is read -- so the arrival from before the
+ // disable was dispatched and restored after all. lastSeen still holds its sequence, so
+ // the supersession check below waves it through too. The generation is the field that
+ // remembers a session ended, which is what the promise is actually about.
+ final int lifecycleAtAdmission = lifecycle;
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ if (!enabled) {
+ // disable() between the two turns. Arriving states are ignored from the
+ // moment it is called, including the ones already admitted.
+ return;
+ }
+ if (lifecycle != lifecycleAtAdmission) {
+ // The session this arrival was admitted into has ended, whether or not one
+ // has been started since.
+ return;
+ }
+ Long newest = lastSeen.get(state.getDeviceId());
+ if (newest == null || newest.longValue() != state.getSequence()) {
+ // Superseded while this was queued: a newer state from the same device was
+ // admitted behind it. Applying this one now would move the user backwards.
+ return;
+ }
+ dispatch(state);
+ }
+ });
+ }
+
+ /// Whether the screen now showing was picked by the APPLICATION when it ended the session,
+ /// rather than left there by the restore that is being undone.
+ ///
+ /// Both happen inside `Navigation.restoreStack()` -- a route factory or a show callback finds
+ /// the session expired, calls `clear()`, and may show a login form before returning -- so
+ /// from out here the two are the same observation: a form that is not the one we started on.
+ /// Re-showing the previous screen over the application's choice puts the user back on the
+ /// signed-out account's UI, which is the opposite of what the callback asked for.
+ ///
+ /// The instant the session ended is where they separate. If the restore had ALREADY changed
+ /// the screen by then, anything different showing now was put there afterwards, and only the
+ /// application could have done that. If the session ended before the restore changed
+ /// anything, whatever is up came from the restore and the undo is right.
+ ///
+ /// Only asked when the restore actually SHOWED something -- see the caller. A route factory
+ /// that ends the session and opens its own form does so before anything is installed, and
+ /// that case is settled there rather than here: with nothing of the restore's on display,
+ /// there is nothing to take down.
+ private static boolean applicationChoseTheScreen(com.codename1.ui.Form beforeRestore,
+ com.codename1.ui.Form now) {
+ if (formAtSessionEnd == null) {
+ return false;
+ }
+ if (formAtSessionEnd == beforeRestore) { //NOPMD CompareObjectsWithEquals
+ // The session ended before the restore had put anything up, so what is showing came
+ // from the restore.
+ return false;
+ }
+ return now != formAtSessionEnd; //NOPMD CompareObjectsWithEquals
+ }
+
+ /// Applies an arrival: offers it to the listeners, then restores or parks it.
+ private static void dispatch(AppState state) {
+ if (isTooOld(state)) {
+ // Rechecked HERE, not only at admission, because admission is not the only way in.
+ // A continuation that cold-launches the app is parked and waits up to
+ // WINDOW_WAIT_MILLIS for the first form, and the waiter then comes back through this
+ // method -- so a state that was fresh when it landed and expired during the wait was
+ // restored anyway, past the check in admit() and the one in getRestorableState().
+ // An expired checkout or booking hold is exactly what maxAge exists to refuse.
+ //
+ // This check existed before the event-thread rewrite and was dropped by it. Its
+ // comment named this path.
+ return;
+ }
+ 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;
+ }
+ // The lifecycle as it stands BEFORE any application code runs. A listener is entitled
+ // to call clear() or disable() -- discovering the arrival belongs to another account is
+ // exactly the decision this callback exists for -- and dispatch used to carry on
+ // regardless: it restored and PERSISTED the signed-out account's state after a logout had
+ // just deleted it, or re-parked it into a session that had been emptied.
+ int lifecycleAtDispatch = lifecycle;
+ // Stamped on the STATE as well, because a listener may keep it. Returning false to hold
+ // an arrival while it prompts the user is documented behaviour, and the object the
+ // application hands back to restore() later carries nothing about the session it came
+ // from -- so a clear() while the prompt was up, and a login for another account after
+ // it, still restored the previous account's payload, routes and checkpoint.
+ state.deliveredGeneration(lifecycleAtDispatch);
+ // A copy, because a listener that reacts by unregistering itself is ordinary and would
+ // otherwise mutate the list being walked.
+ List snapshot = new ArrayList(listeners);
+ for (ContinuityListener l : snapshot) {
+ boolean accepted = false;
+ boolean threw = false;
+ try {
+ accepted = l.stateReceived(state);
+ } catch (Throwable t) {
+ Log.e(t);
+ threw = true;
+ }
+ // clear() or disable() ran inside the callback. Everything after this point --
+ // asking the next listener, restoring, persisting, parking, marking -- would be
+ // acting for a session that no longer exists.
+ //
+ // ONE check, placed before the `continue`. It used to sit after it, so a listener
+ // that signed out and then THREW jumped straight past and the next listener was
+ // handed the signed-out account's state. The check at the bottom of this method
+ // stops the restore, but it cannot undo what that listener did with the payload, or
+ // unsee it.
+ if (lifecycle != lifecycleAtDispatch) {
+ return;
+ }
+ if (threw) {
+ 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.
+ //
+ // PARKED, not simply dropped. False has two documented meanings -- "I did the work
+ // myself" and "keep it, I will prompt and call restore() when the user accepts" --
+ // and the second one is a state waiting on a human, whose only other copy is the
+ // relay's. Returning without the slot left no hold, so a queued checkpoint could
+ // replace that copy while the prompt was still up, and a process death before the
+ // answer lost the work for good.
+ //
+ // Safe for the first meaning too: acknowledge() releases the slot, which is the
+ // call that meaning is documented to make. Whichever the application meant, the
+ // hold ends when it says so rather than being guessed at here.
+ if (!isAlreadyActedOn(state)) {
+ placeOnOffer(state);
+ }
+ return;
+ }
+ }
+ if (lifecycle != lifecycleAtDispatch) {
+ // Checked again after the LAST listener as well, not only between them.
+ return;
+ }
+ if (autoRestore) {
+ // The mark is written by restore(), through commit(), and ONLY when it committed
+ // something. There was an unconditional rememberSeen() here, which quietly undid that:
+ // admit() has already put this sequence in the live map, so persisting the map wrote
+ // the mark for a state whose checkpoint had failed to store, or that applied nothing
+ // at all. After a restart enable() reloads it and the relay's only recoverable copy is
+ // refused -- the very loss commit() gates against, reached down a second path that
+ // never went through it.
+ //
+ // The in-memory mark still goes in at admission, which is what dedups within a run.
+ // Durability is a separate question and has one owner.
+ boolean[] restoreFailed = new boolean[1];
+ restore(state, restoreFailed);
+ if (lifecycle != lifecycleAtDispatch) {
+ // The PROVIDER ended the session while restoring. Parking below would put the
+ // arrival back into a session that has just been cleared -- getRestorableState()
+ // would go on offering the signed-out account's work, which is the thing logout
+ // exists to prevent.
+ //
+ // Third place the same question had to be asked: after the listeners, inside
+ // restore() around the provider, and here. Each is a point where application code
+ // has just run and may have ended everything.
+ return;
+ }
+ if (restoreFailed[0]) {
+ // PARKED, exactly as a deferred arrival is. An automatic restore that failed is
+ // an arrival nobody has dealt with: pollFinished() has already queued a publisher
+ // behind this dispatch, and with the slot empty it posts the pending local
+ // checkpoint over the relay's only copy of the state that just failed -- so the
+ // retry this failure is kept for has nothing left to retry.
+ //
+ // The answer was thrown away here. restore(state) has always known the
+ // difference; this call site simply did not ask.
+ placeOnOffer(state);
+ }
+ } else {
+ placeOnOffer(state);
+ }
+ }
+
+ /// Puts a state on offer, without silently losing the one it replaces.
+ ///
+ /// EVERY path that offers a state comes through here, and that is the point rather than a
+ /// tidiness: the bookkeeping was added at two of the five and a listener that declines two
+ /// arrivals in a row still lost the first. The others are a cold-launch hold, the wait for a
+ /// first window, and the pre-enable hold -- each of them a place where a second arrival can
+ /// find one already waiting.
+ ///
+ /// The slot holds one arrival, which is right: `getRestorableState()` answers with a state,
+ /// and an application that has not dealt with the last one does not want a queue growing
+ /// behind it. Replacement is right too when the two come from the SAME device -- that is
+ /// supersession, and the newer sequence is the one worth showing.
+ ///
+ /// Two different devices are not that. With automatic restoration off, both can be dispatched
+ /// before the application calls restore(), and the second simply overwrote the first -- which
+ /// would be survivable if the first could come back, and it could not: its (origin, sequence)
+ /// is in the in-memory map from admission, so a redelivery in the same run is refused as
+ /// already seen. Recorded as handled and then dropped.
+ ///
+ /// So the displaced state goes to the shelf instead. Forgetting its admission mark -- which
+ /// is what this did before -- only permitted a redelivery that is not coming, because the
+ /// off-EDT callback claims what it queues and the port is free to let go of its copy. See the
+ /// `shelved` field.
+ private static void placeOnOffer(AppState state) {
+ AppState replaced = parked;
+ if (replaced != null && replaced != state) { //NOPMD CompareObjectsWithEquals
+ if (isSameOrigin(replaced, state)) {
+ // SAME device, so this is supersession -- and supersession has a direction. The
+ // comment above has always said the newer sequence is the one worth showing, and
+ // nothing checked: arrivals do not necessarily land in the order they were sent,
+ // and a delayed sequence 10 landing after 11 moved the user BACKWARD. admit() has
+ // this check, but the pre-enable path does not go through admit(), so the states a
+ // synced-store listener's seam collects before enable() arrive here unordered and
+ // both copies have already been claimed from the port.
+ if (state.getSequence() < replaced.getSequence()) {
+ return;
+ }
+ } else {
+ shelve(replaced);
+ }
+ }
+ parked = state;
+ String origin = state.getDeviceId();
+ if (origin == null || origin.length() == 0) {
+ // Nothing to reconcile, and asking would be wrong: the shelf keys an unidentified
+ // state under a null origin, so looking one up here would pull back the state this
+ // call had just displaced -- and, if its sequence happened to be the higher of the
+ // two, hand it the slot and drop the arrival that displaced it.
+ return;
+ }
+ // The slot and the shelf never hold one origin twice. Whatever this origin had shelved is
+ // this arrival's predecessor -- or, if the two crossed on the wire, its successor, and
+ // then the newer one takes the slot back under the same direction rule as above.
+ AppState kept = shelved.remove(origin);
+ if (kept != null && kept != state //NOPMD CompareObjectsWithEquals
+ && kept.getSequence() > state.getSequence()) {
+ parked = kept;
+ }
+ }
+
+ /// Keeps a displaced arrival, newest per origin.
+ ///
+ /// One entry per device rather than a queue: an origin that has moved on has superseded its
+ /// own earlier state, which is the rule supersedesParked() already applies to the slot. So the
+ /// shelf grows with how many devices the user has, not with how many states they send -- and
+ /// then MAX_SHELVED bounds even that, because the device ids come off the wire and nothing
+ /// here gets to trust them.
+ private static void shelve(AppState state) {
+ String origin = state.getDeviceId();
+ if (origin == null || origin.length() == 0) {
+ // Not shelved at all. Everything the shelf does -- supersede, settle, promote -- is
+ // keyed by origin, so a state that does not say where it came from cannot take part
+ // in any of it. And it is never the arrival this exists for: a cross-device
+ // continuation always carries an id, and what reaches here without one is a state the
+ // application built and handed to restore(), which it still holds a reference to.
+ return;
+ }
+ AppState kept = shelved.get(origin);
+ if (kept != null && kept.getSequence() > state.getSequence()) {
+ return;
+ }
+ shelved.put(origin, state);
+ while (shelved.size() > MAX_SHELVED) {
+ AppState oldest = null;
+ Iterator i = shelved.values().iterator();
+ while (i.hasNext()) {
+ AppState candidate = i.next();
+ if (oldest == null || candidate.getTimestamp() < oldest.getTimestamp()) {
+ oldest = candidate;
+ }
+ }
+ if (oldest == null) {
+ // Unreachable while size() is over the cap, and the loop must not spin if it ever
+ // is not.
+ break;
+ }
+ shelved.remove(oldest.getDeviceId());
+ }
+ }
+
+ /// Whether two arrivals came from the same device.
+ ///
+ /// An absent origin is not a device: two states that both fail to say where they came from
+ /// are not each other's supersession, so they displace rather than replace.
+ private static boolean isSameOrigin(AppState a, AppState b) {
+ String origin = a.getDeviceId();
+ return origin != null && origin.length() > 0 && origin.equals(b.getDeviceId());
+ }
+
+ /// The arrival on offer: the slot, promoting the newest shelved one when it has emptied, and
+ /// expiring whatever has aged out on the way past.
+ ///
+ /// Expiry is applied HERE rather than when the state is shelved because getMaxAge() is
+ /// measured when the question is asked -- an arrival that was fresh when it was displaced can
+ /// be stale by the time the slot frees up, and that wait is exactly what the age is for.
+ private static AppState nextOffer() {
+ while (true) {
+ // Through a local, for the reason startPublisher() gives: PMD's NonThreadSafeSingleton
+ // matches the SHAPE of "null-check a static, then assign that static inside the
+ // branch" and reports it as a lazy initializer. This is a slot being refilled, not an
+ // instance being created, and the project's gate has no per-finding allow list.
+ AppState offer = parked;
+ if (offer == null) {
+ offer = takeShelved();
+ if (offer == null) {
+ return null;
+ }
+ parked = offer;
+ }
+ if (!isTooOld(offer)) {
+ return offer;
+ }
+ // Cleared, and then we keep looking -- past the rest of the shelf and, in the caller,
+ // on to the stored checkpoint. Returning null at the first expired arrival reported
+ // "nothing to restore" while a perfectly valid state sat behind it.
+ parked = null;
+ // And the checkpoint that was waiting behind it goes out. The hold is there to
+ // protect the relay's only copy of a live arrival; this one has expired and will not
+ // be restored by anything, so holding a publication for it forever is just a
+ // checkpoint that never reaches the user's other devices.
+ startPublisher();
+ }
+ }
+
+ /// Removes and returns the newest shelved arrival, or null.
+ private static AppState takeShelved() {
+ AppState newest = newestShelved();
+ if (newest != null) {
+ shelved.remove(newest.getDeviceId());
+ }
+ return newest;
+ }
+
+ /// The newest shelved arrival without removing it, or null.
+ ///
+ /// Newest by the ORIGIN's clock. Sequences are per-device counters, so comparing one device's
+ /// against another's says nothing at all -- the timestamp is the only ordering two devices
+ /// share, and it is the one last-writer-wins already uses.
+ private static AppState newestShelved() {
+ AppState newest = null;
+ Iterator i = shelved.values().iterator();
+ while (i.hasNext()) {
+ AppState candidate = i.next();
+ if (newest == null || candidate.getTimestamp() > newest.getTimestamp()) {
+ newest = candidate;
+ }
+ }
+ return newest;
+ }
+
+ /// Drops shelved arrivals that have aged past getMaxAge().
+ ///
+ /// Called from startPublisher() as well as nextOffer(), because the shelf holds publication
+ /// back the same way the slot does: without this, an application that never asks for a
+ /// restorable state would stop publishing for the rest of the process over an arrival that
+ /// expired hours ago.
+ private static void purgeShelf() {
+ if (shelved.isEmpty()) {
+ return;
+ }
+ Iterator i = shelved.values().iterator();
+ while (i.hasNext()) {
+ if (isTooOld(i.next())) {
+ i.remove();
+ }
+ }
+ }
+
+ /// Drops whatever `state` settles from the shelf: the same origin, at or behind it.
+ ///
+ /// The shelf is subject to every way an arrival ends, exactly as the slot is -- a restore, an
+ /// acknowledgement, or a tombstone from that origin finishes its shelved state too. Missing
+ /// this would leave work on offer that the origin has already moved past, and hold this
+ /// device's checkpoints behind it.
+ /// #### Returns
+ ///
+ /// true when this settled a shelved arrival, so the caller knows a publication hold may have
+ /// just been released. The shelf holds checkpoints back exactly as the slot does, and the
+ /// application can settle a SHELVED state directly -- a listener that kept its own reference
+ /// to the arrival it deferred and acknowledges that, rather than promoting it through
+ /// getRestorableState() first. The release beside every one of these calls is keyed to the
+ /// SLOT emptying, so nothing let the publisher go on that path and the queued checkpoint sat
+ /// until some unrelated later one happened to start it.
+ private static boolean settleShelved(AppState state) {
+ String origin = state.getDeviceId();
+ if (origin == null || origin.length() == 0) {
+ return false;
+ }
+ AppState kept = shelved.get(origin);
+ if (kept != null && kept.getSequence() <= state.getSequence()) {
+ shelved.remove(origin);
+ return true;
+ }
+ return false;
+ }
+
+ /// Whether this state has already been marked handled.
+ ///
+ /// Asked before a deferred arrival is parked, because the documented handle-it-yourself
+ /// pattern does BOTH: the listener calls acknowledge(state) and then returns false. That runs
+ /// noteActedOn() first, while there is nothing parked for it to release, and parking
+ /// afterwards left an acknowledged state on offer for the rest of the process with every
+ /// relay checkpoint held behind it -- the exact hold the parking was added to provide,
+ /// applied to work that was already finished.
+ ///
+ /// The in-memory durable map, not the stored one: recordDurable() fills it whether or not the
+ /// write to storage succeeded, and what is being asked here is what this process has done, not
+ /// what survived to disk.
+ private static boolean isAlreadyActedOn(AppState state) {
+ // The in-process record FIRST, because durableSeen is bounded by what one stored string
+ // can hold and trimToWritable() can evict an entry the moment it goes in -- a device id
+ // long enough to blow the budget on its own does exactly that. Asking only the map then
+ // said a state acknowledged a microsecond earlier had not been acted on, and parked it:
+ // still offered, with every relay checkpoint held behind work that was already finished.
+ //
+ // Two fixes of mine meeting. Neither is wrong on its own; the map answers "what will the
+ // next launch know", and this question is "what has this process already done".
+ AppState done = lastCompleted;
+ if (done != null && done.getDeviceId().equals(state.getDeviceId())
+ && done.getSequence() >= state.getSequence()) {
+ return true;
+ }
+ Long mark = durableSeen.get(state.getDeviceId());
+ return mark != null && mark.longValue() >= state.getSequence();
+ }
+
+ /// Holds a cold-launch arrival until the application has a form to restore into.
+ ///
+ /// The waiter is a thread only because there is nothing on the EDT to wait on -- no form
+ /// exists yet, so there is no timer to bind to. It touches no field of this class and no UI
+ /// state: it sleeps, asks the event thread whether a window has appeared, and hands the
+ /// decision back to it.
+ private static void park(AppState state) {
+ placeOnOffer(state);
+ if (waitingForWindow) {
+ return;
+ }
+ waitingForWindow = true;
+ Display.getInstance().startThread(new Runnable() {
+ @Override
+ public void run() {
+ final long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS;
+ try {
+ while (System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException err) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ // The REMAINING budget, so the question cannot outlast the window it is
+ // being asked inside. An untimed wait here blocks until the event thread
+ // reaches the runnable, and a cold launch is exactly when it may not for a
+ // long time -- a slow device building its first forms. The loop then could
+ // not recheck its own deadline, so a "bounded 15 seconds" wait ran for as
+ // long as the event thread was busy.
+ if (haveWindow(deadline - System.currentTimeMillis())) {
+ break;
+ }
+ }
+ } finally {
+ // ALWAYS, whatever happened above. waitingForWindow is what stops a second
+ // waiter being started, so a throw that skipped this notification left it set
+ // for the rest of the process: the arrival stays parked, and every later
+ // arrival parks behind it without anything ever coming to look.
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ windowWaitFinished(deadline);
+ }
+ });
+ }
+ }
+ }, "Continuity window wait").start();
+ }
+
+ /// Whether a form is on screen, asked ON THE EVENT THREAD from the cold-launch waiter.
+ ///
+ /// `Display.getCurrent()` is not a plain field read. When the current form is a disposed
+ /// dialog or a menu it walks `animationQueue` by index -- size taken first, then each
+ /// element -- and a cold launch is precisely when the event thread is building forms and
+ /// running transitions through that queue. Reading it from this worker could throw
+ /// IndexOutOfBoundsException, which is worse than it sounds: the exception left
+ /// `waitingForWindow` set, so the arrival stayed parked and no later arrival could start a
+ /// waiter either.
+ ///
+ /// Marshalled rather than guarded. This framework is single threaded on the event thread and
+ /// the UI belongs to it; the fix for touching it from elsewhere is to stop doing that, not to
+ /// put a lock around state that has no business being shared.
+ private static boolean haveWindow(long budgetMillis) {
+ if (budgetMillis <= 0) {
+ return false;
+ }
+ final boolean[] present = new boolean[1];
+ try {
+ // The TIMED overload. On timeout it simply returns, leaving `present` false -- "not
+ // yet", which is the same answer a launch with no form gives and which the loop
+ // already handles by going round again or ending on the deadline. The runnable may
+ // run later and write to the array after we have stopped reading it; nothing else
+ // ever looks at it, so that write goes nowhere.
+ Display.getInstance().callSeriallyAndWait(new Runnable() {
+ @Override
+ public void run() {
+ present[0] = Display.getInstance().getCurrent() != null;
+ }
+ }, (int) Math.min(budgetMillis, (long) Integer.MAX_VALUE));
+ } catch (Throwable t) {
+ // Answering "not yet" keeps the wait going, and the deadline still ends it. The
+ // caller's finally reports back either way.
+ Log.e(t);
+ }
+ return present[0];
+ }
+
+ /// The cold-launch wait is over. On the EDT.
+ ///
+ /// `deadline` is when the window closed, and it is checked HERE as well as in the loop because
+ /// this half runs on the event thread too: the waiter hands back through callSerially, so an
+ /// event thread that was busy past the deadline runs this whenever it recovers. Bounding only
+ /// the wait would have left that -- the arrival dispatched minutes later, replacing whatever
+ /// the user had started doing in the meantime, which is the interruption the bounded window
+ /// exists to rule out.
+ ///
+ /// Past the deadline the state stays PARKED rather than being dropped. Nothing has dealt with
+ /// it, so getRestorableState() goes on offering it and the application can restore it when it
+ /// chooses -- the same answer this method already gives when no form ever appeared.
+ private static void windowWaitFinished(long deadline) {
+ waitingForWindow = false;
+ if (deadline > 0 && System.currentTimeMillis() >= deadline) {
+ return;
+ }
+ if (Display.getInstance().getCurrent() == null) {
+ // Still no form after the whole wait. The state stays parked, so an application that
+ // gets going later can still ask for it through getRestorableState().
+ return;
+ }
+ // Taken and cleared rather than compared against the state the waiter was started for. A
+ // newer arrival while it waited is the one worth showing.
+ //
+ // The SHELF as well as the slot, and for the third time in this class: two devices can
+ // both reach the callback before the first form exists, and the second displaces the
+ // first onto the shelf. Dispatching only the slot left that first arrival with nothing
+ // coming for it -- never handed to a listener even with automatic restoration on, and
+ // reachable only if the application called getRestorableState() by hand -- while it went
+ // on holding every relay publication behind it.
+ //
+ // The generalisation, since patching one path at a time has now missed three: a SECOND
+ // holder means every path that empties EITHER of them has to deal with both. The
+ // pre-enable drain, the settles, and this.
+ //
+ // dispatch(), not admit(): everything here has already been through admission, which is
+ // how it came to be parked. Snapshotted and cleared before any of it is dispatched,
+ // because a listener that defers lands straight back in placeOnOffer().
+ AppState[] pending = takeAllPendingOffers();
+ // The session as it stands before any listener runs. dispatch() hands each state to
+ // application code, and that code is allowed to end the session -- a listener that finds
+ // the account signed out calls clear(), which is the documented shape. Everything after it
+ // in this array arrived BEFORE that happened, so continuing would offer the previous
+ // session's work to the one that replaced it, and dispatch() cannot notice on its own: it
+ // has no entry check for `enabled` and samples the generation afresh, so the next state
+ // looks like it belongs to the session it is actually crossing into.
+ //
+ // The sibling drain has had this since it was written -- "a listener that ran during this
+ // drain is allowed to turn continuity off" -- and this loop was added later without it.
+ int drainingIn = lifecycle;
+ for (AppState state : pending) {
+ if (!stillTheSameSession(drainingIn)) {
+ return;
+ }
+ dispatch(state);
+ }
+ // Whether they dispatched or were refused, neither holder is keeping anything back now.
+ startPublisher();
+ }
+
+ /// Reports a continuation payload big enough that the platform may not carry it.
+ ///
+ /// Advisory, not a limit: the threshold below is this framework's own conservative reading of
+ /// "small", not a constant Apple publishes, which is exactly why nothing is refused on it.
+ ///
+ /// Once per session. A checkpoint runs on every route change, so a per-publish message would
+ /// bury the log of an application that is simply carrying a lot of state -- and it is the
+ /// same state each time, so saying it again adds nothing.
+ private static void warnIfLargeForHandoff(AppState state) {
+ if (handoffSizeReported) {
+ return;
+ }
+ int size;
+ try {
+ size = StateCodec.encodedSize(state);
+ } catch (Throwable t) {
+ // Measuring must never be the thing that stops a publish.
+ Log.e(t);
+ return;
+ }
+ if (size <= HANDOFF_ADVISORY_CHARS) {
+ return;
+ }
+ handoffSizeReported = true;
+ Log.p("Continuity: this checkpoint encodes to " + size + " characters, which is larger "
+ + "than a continuation's userInfo is meant to carry. Saving and restoring on this "
+ + "device is unaffected, and so is any relay -- but the hand-off to a nearby "
+ + "device may not arrive. Put an identifier in the payload and fetch the rest on "
+ + "the other side.");
+ }
+
+ /// A fresh origin id, minted once per install and persisted.
+ ///
+ /// NOT Util.getUUID(), and the reason is worth the paragraph. Instantiating Util.UUID runs its
+ /// static initialiser, which seeds itself from getUniqueDeviceID() -- and that reaches
+ /// CN.getProperty("User-Agent"), which the Apple ports answer by standing up a web view. On
+ /// tvOS there is no WebKit at all, so the call never returns: enable() hung on the event
+ /// thread, and the device suite stopped dead on the first test that installs a state
+ /// provider, taking every test after it with it. The iOS log makes the mechanism plain --
+ /// a thousand WebKit lines starting at exactly that instant -- and the tvOS log has not one.
+ ///
+ /// An id that identifies one installation to the devices it syncs with does not need a
+ /// browser's user agent to seed it, and must not need a working web view to be minted. Time
+ /// plus two draws from a Random seeded independently of it is ample: this runs ONCE per
+ /// install, and the result is written to storage.
+ private static String mintDeviceId() {
+ long time = System.currentTimeMillis();
+ java.util.Random random = new java.util.Random(time ^ (long) new Object().hashCode());
+ return "cn1-" + hex64(time) + "-" + hex64(random.nextLong()) + hex64(random.nextLong());
+ }
+
+ /// A long as sixteen hex digits, through Integer.toHexString on each half.
+ ///
+ /// Long.toHexString is in neither vm/JavaAPI nor Ports/CLDC11, and the Maven build compiles
+ /// core against the full JDK and would have accepted it.
+ private static String hex64(long value) {
+ return hex32((int) (value >>> 32)) + hex32((int) value);
+ }
+
+ private static String hex32(int value) {
+ String hex = Integer.toHexString(value);
+ StringBuilder out = new StringBuilder();
+ for (int pad = hex.length(); pad < 8; pad++) {
+ out.append('0');
+ }
+ out.append(hex);
+ return out.toString();
+ }
+
+ private static String loadDeviceId() {
+ try {
+ String id = null;
+ if (Display.isInitialized() && Storage.getInstance().exists(PREF_DEVICE_ID)) {
+ Object o = Storage.getInstance().readObject(PREF_DEVICE_ID);
+ if (o instanceof String) {
+ id = (String) o;
+ }
+ }
+ if (id == null || id.length() == 0) {
+ id = mintDeviceId();
+ if (!Storage.getInstance().writeObject(PREF_DEVICE_ID, id)) {
+ // Minted but not stored, so the next launch mints another one and every state
+ // this device has sent starts looking like a stranger's. Nothing here can
+ // prevent that; saying so beats a silent identity change.
+ Log.p("Continuity: the device id could not be stored; it will change on the "
+ + "next launch and states already sent will not be recognized as "
+ + "this device's own.");
+ }
+ }
+ 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();
+ }
+ }
+
+ /// Records that `state` has been acted on, durably.
+ private static void noteActedOn(AppState state) {
+ String from = state.getDeviceId();
+ if (from == null || from.length() == 0 || from.equals(getDeviceId())) {
+ // Our own work needs no mark: deliver() drops an echo on the device id alone.
+ return;
+ }
+ long seq = state.getSequence();
+ Long inMemory = lastSeen.get(from);
+ if (inMemory == null || inMemory.longValue() < seq) {
+ recordSeen(from, seq, true);
+ } else {
+ // The in-memory mark already covers this state: admit() put it there on the way in,
+ // which is the ordinary case, so "only if higher" never fires and the DURABLE half
+ // would never be written. That is the same bug the note below describes, returned in
+ // a new shape once the durable set stopped being the in-memory one -- writing the
+ // whole of memory used to hide it.
+ //
+ // Marked only up to THIS state. If something newer from the same device has been
+ // admitted since, it has not been completed and must not be marked on this one's
+ // behalf.
+ Long durable = durableSeen.get(from);
+ if (durable == null || durable.longValue() < seq) {
+ recordDurable(from, seq);
+ }
+ }
+ boolean shelfSettled = settleShelved(state);
+ if (supersedesParked(state)) {
+ // The application has dealt with this arrival -- acknowledge() is the documented way
+ // to decline one -- so the slot must not go on offering it through
+ // getRestorableState(), and must not go on holding a checkpoint back either. The
+ // hold exists because a parked state's only copy is on the relay; an acknowledged
+ // state has a durable mark, so overwriting the relay's copy is now safe.
+ parked = null;
+ startPublisher();
+ } else if (shelfSettled) {
+ // Settled off the SHELF without the slot changing, which is what a listener that kept
+ // its own reference to a deferred arrival does. The shelf holds publication too, so
+ // the hold has to be released here as well.
+ startPublisher();
+ }
+ // Recorded before the write, because it is not about the write. What this process has
+ // dealt with is true whether or not the mark reaches storage or survives the size budget.
+ lastCompleted = state;
+ // ALWAYS, not only when a map moved. The condition this replaced was written when the
+ // durable copy tracked memory exactly; it does not, and by the time anything calls this
+ // memory already holds the entry, so "unchanged" meant "write nothing" and both
+ // acknowledge() and the restore path silently persisted nothing at all.
+ rememberSeen();
+ }
+
+ /// Whether `r` is still the relay this application has installed.
+ ///
+ /// Package visible for RestStateRelay, which asks it on the CREDENTIAL path -- the last place
+ /// a check can be put before a token is read. The publish worker already confirms its session
+ /// on the event thread before calling the relay, and that leaves one gap it cannot close: the
+ /// worker is a different thread, so between the confirmation returning and the relay reading
+ /// its token, a logout and a login can both have happened. getToken() is documented to be
+ /// read at each request precisely so a refreshed session is followed, which means the relay
+ /// would then authenticate the PREVIOUS account's state with the NEXT account's credentials.
+ ///
+ /// Not the same as the session check and not a replacement for it: that one stops the work,
+ /// this one stops the credentials. A check cannot be atomic with the read that follows it, so
+ /// what this buys is the distance between them -- instructions on one thread instead of an
+ /// unbounded wait on a queue. Closing it completely would mean binding the token inside the
+ /// confirmation, which no framework code can do: resolving credentials is the relay's own
+ /// business and the interface deliberately does not reach into it.
+ static boolean isInstalledRelay(StateRelay r) {
+ // IDENTITY, and the marker says so: two relays that considered themselves equal would
+ // still be two objects, and the one that was replaced is the one that must be refused.
+ return r != null && r == relay; //NOPMD CompareObjectsWithEquals
+ }
+
+ /// Whether `r` may send RIGHT NOW: it is the installed relay, and the session its caller
+ /// belongs to has not ended.
+ ///
+ /// The identity half alone answered only one of the two questions. `setRelay()` swaps the
+ /// object, so a replaced relay is caught -- but `clear()` deliberately leaves the same relay
+ /// INSTALLED, because the same endpoint usually serves the next account. Its logout was
+ /// therefore invisible here: a worker whose preflight passed a moment before `clear()` ran
+ /// found its relay still installed and sent the previous account's state anyway, which is
+ /// exactly what `clear()` promises not to allow. Cookie or client-certificate authentication
+ /// makes that concrete -- there is no token for `getToken()` to have stopped returning.
+ ///
+ /// So the session is asked as well, and asked on the EVENT THREAD, because `relaySession`
+ /// belongs to it. Blocking a worker on the EDT is the safe direction: the EDT never waits on
+ /// a worker.
+ ///
+ /// Only a framework worker has a session bound to it. A relay the application drives itself
+ /// -- `RestStateRelay` is a public class and usable on its own -- has none, and gets the
+ /// identity answer it always got; refusing those would break a legitimate direct call for a
+ /// session it was never part of.
+ ///
+ /// What this still cannot close is the instant between this answer and the request that
+ /// follows it. That is instructions on one thread rather than an unbounded wait on a queue,
+ /// which is the whole of what a check placed here can buy.
+ static boolean mayRelaySend(final StateRelay r) {
+ if (r == null) {
+ return false;
+ }
+ Integer bound = RELAY_CALL_SESSION.get();
+ if (bound == null) {
+ // Not a framework worker: the application is driving this relay itself. Refused only
+ // when a DIFFERENT relay is installed, which is the confusion this guards against --
+ // an object kept across a setRelay() and used afterwards, sending the previous
+ // account's state under the next account's credentials.
+ //
+ // isInstalledRelay() alone answered false for a relay that was never installed at
+ // all, so a RestStateRelay used on its own -- a public class with a public
+ // constructor -- had every publish() and fetch() throw before issuing a request. The
+ // comment here claimed that case worked; it did not, and there is no session for it
+ // to confuse.
+ return relay == null || r == relay; //NOPMD CompareObjectsWithEquals
+ }
+ final int session = bound.intValue();
+ final boolean[] live = new boolean[1];
+ try {
+ Display.getInstance().callSeriallyAndWait(new Runnable() {
+ @Override
+ public void run() {
+ live[0] = r == relay //NOPMD CompareObjectsWithEquals
+ && session == relaySession;
+ }
+ });
+ } catch (Throwable t) {
+ // Refused, not allowed. Whatever stopped the event thread from answering, sending
+ // under an unknown session is the outcome this method exists to prevent.
+ Log.e(t);
+ return false;
+ }
+ return live[0];
+ }
+
+ /// The routes of a remote state that this device can actually keep.
+ ///
+ /// fromJson accepts a remote document's routes UNCHECKED, deliberately: another device's
+ /// mistake must not become an exception here. But an accepted route still ends up in the live
+ /// navigation stack, and the next checkpoint reads that stack back through the validating
+ /// setter -- so one route past this device's stored-string limit threw out of capture(), left
+ /// the pending flag set, and every later navigation retried the same throw while nothing was
+ /// persisted or published again.
+ ///
+ /// Dropped here instead, before it can enter the stack. A route that can never be
+ /// checkpointed has no business becoming the user's history, and saying so once is better
+ /// than a capture that fails for ever without explaining itself.
+ private static List usableRoutes(List routes) {
+ List out = new ArrayList();
+ for (String route : routes) {
+ if (route != null && !StateCodec.exceedsWritableLength(route)) {
+ out.add(route);
+ continue;
+ }
+ Log.p("Continuity: ignoring a route from another device that is longer than this "
+ + "device can store. The rest of the state is restored.");
+ }
+ return out;
+ }
+
+ /// Whether completing `state` also finishes whatever is parked.
+ ///
+ /// Not identity. A device can have two states in flight -- a continuation and a relay poll
+ /// routinely carry different sequences -- so sequence N can be parked while N+1 from the same
+ /// origin is admitted and restored. Asking only "is this the same state" left N in the slot:
+ /// getRestorableState() went on offering work the origin had already moved past, restoring it
+ /// would have walked the user and the stored checkpoint BACKWARDS, and the publication hold
+ /// never lifted.
+ ///
+ /// The same rule the tombstone path applies, for the same reason: an origin telling us where
+ /// it is now settles everything of its own that came before. A state from a DIFFERENT origin
+ /// settles nothing here, which is what keeps this device's own checkpoint from releasing a
+ /// hold that belongs to somebody else's arrival.
+ private static boolean supersedesParked(AppState state) {
+ AppState waiting = parked;
+ return waiting != null
+ && waiting.getDeviceId().equals(state.getDeviceId())
+ && waiting.getSequence() <= state.getSequence();
+ }
+
+ /// Test seam: the inbound callback a port is given, so the decline-while-disabled answer can
+ /// be asked directly rather than through a platform.
+ static ContinuityCallback callbackForTest() {
+ return new Callback();
+ }
+
+ /// Test seam: the form a session-end recorded for the restore comparison, or null.
+ ///
+ /// The retention is the point of asking. This holds a whole component tree, and an ordinary
+ /// logout used to fill it for a comparison that only ever happens during a restore.
+ static com.codename1.ui.Form formAtSessionEndForTest() {
+ return formAtSessionEnd;
+ }
+
+ /// Test seam: parks a state, as a cold-launch arrival with no form yet does.
+ static void parkForTest(AppState state) {
+ placeOnOffer(state);
+ }
+
+ /// Test seam: the cold-launch drain, entered exactly where the waiter enters it.
+ ///
+ /// The wait itself cannot be reproduced in a unit harness -- it needs a launch with no form,
+ /// and this one always has one -- but the drain is the half that matters: it is where a state
+ /// that was fresh when it arrived and expired while waiting reaches dispatch().
+ static void drainParkedForTest() {
+ // No deadline: the harness is entering the drain directly, not finishing a timed wait.
+ windowWaitFinished(0L);
+ }
+
+ /// Test seam: the same drain, entered after the cold-launch window has already closed.
+ ///
+ /// The event thread being busy past the deadline is the case that cannot be staged here --
+ /// this harness always has a form and a responsive thread -- but the decision it leads to is
+ /// exactly this call, so that is what is pinned.
+ static void drainParkedPastTheWindowForTest() {
+ windowWaitFinished(System.currentTimeMillis() - 1L);
+ }
+
+ /// Test seam: the marks as they would be reloaded on the next launch.
+ static Map readSeenForTest() {
+ return readSeen();
+ }
+
+ /// Test seam: how many devices the live map is holding.
+ static int seenSizeForTest() {
+ return lastSeen.size();
+ }
+
+ /// Records a device's high-water mark, most recently used LAST.
+ ///
+ /// Removed before it is put back, because the map keeps INSERTION order and a plain put()
+ /// over an existing key leaves it where it first appeared -- so a device that has been active
+ /// all along would still be evicted ahead of one that has said nothing since.
+ private static void recordSeen(String device, long sequence, boolean durable) {
+ lastSeen.remove(device);
+ lastSeen.put(device, Long.valueOf(sequence));
+ if (durable) {
+ durableSeen.remove(device);
+ durableSeen.put(device, Long.valueOf(sequence));
+ }
+ trimSeen();
+ }
+
+ /// The highest sequence known for a device, from EITHER map.
+ ///
+ /// The two are bounded independently and hold different sets -- lastSeen takes every arrival,
+ /// durableSeen only the ones that completed -- so they evict at different rates and an origin
+ /// can survive in one after being dropped from the other. Asking only lastSeen therefore let
+ /// a duplicate through: acknowledge a state, admit more than MAX_SEEN other origins, and the
+ /// acknowledged one is evicted from lastSeen while its durable mark remains. The duplicate
+ /// then passed the check and ran the application's listeners a second time, against the
+ /// act-once guarantee the durable mark exists to give.
+ private static Long seenSequence(String device) {
+ Long inMemory = lastSeen.get(device);
+ Long durable = durableSeen.get(device);
+ if (inMemory == null) {
+ return durable;
+ }
+ if (durable == null) {
+ return inMemory;
+ }
+ return durable.longValue() > inMemory.longValue() ? durable : inMemory;
+ }
+
+ /// Marks a device durably without disturbing the in-memory dedup mark, which may be newer.
+ private static void recordDurable(String device, long sequence) {
+ durableSeen.remove(device);
+ durableSeen.put(device, Long.valueOf(sequence));
+ trimSeen();
+ }
+
+ /// Evicts the least recently seen devices until the map is back inside MAX_SEEN.
+ ///
+ /// The LIVE map, not a copy taken on the way to storage. A user has a handful of devices, but
+ /// the ids arrive from a relay and nothing stops one from feeding many, which is the reason
+ /// there is a cap at all -- so it has to apply where entries are added rather than where they
+ /// happen to be written out.
+ ///
+ /// The lowest sequences go: those are the devices that have been quiet longest, and losing a
+ /// mark costs one duplicate delivery rather than anything durable.
+ private static void trimSeen() {
+ trimTo(durableSeen);
+ trimTo(lastSeen);
+ // durableSeen only: lastSeen is never written anywhere, so no byte budget applies to it.
+ trimToWritable(durableSeen);
+ }
+
+ /// Evicts the eldest durable marks until the whole map still fits in one stored string.
+ ///
+ /// MAX_SEEN bounds the COUNT, which is not the same bound. The marks go to storage as a single
+ /// string, and a stored string is written as modified UTF-8 with a length that stops at
+ /// 65535 bytes -- while a device id is only checked against that limit ONE AT A TIME, on its
+ /// way into an AppState. Ids arrive from other devices, so a single maximum-length one already
+ /// makes the combined string too long on its own, and a few merely large ones do it together.
+ ///
+ /// The write then fails every time, and the failure is the quiet kind: this run still
+ /// acknowledges correctly from memory, and nothing is durable, so after every restart the
+ /// relay can offer an already-applied state again and its side effects run a second time.
+ ///
+ /// Evicting is the same trade the count cap already makes, and the same victims: the eldest
+ /// go, and losing a mark costs one duplicate delivery rather than the durability of all of
+ /// them. An id so long that it does not fit beside anything is evicted by the same loop.
+ private static void trimToWritable(Map map) {
+ int total = 0;
+ for (Map.Entry e : map.entrySet()) {
+ total += seenEntryLength(e.getKey(), e.getValue().longValue());
+ }
+ Iterator> i = map.entrySet().iterator();
+ while (total > StateCodec.MAX_STRING_BYTES && i.hasNext()) {
+ Map.Entry e = i.next();
+ total -= seenEntryLength(e.getKey(), e.getValue().longValue());
+ i.remove();
+ }
+ }
+
+ /// What one mark costs in the stored string, separators included.
+ ///
+ /// The trailing separator is counted for every entry including the last, which over-counts by
+ /// one byte. That is the safe direction for a budget and it keeps the sum independent of
+ /// which entry happens to be last, so removing one from the front never invalidates the rest.
+ private static int seenEntryLength(String device, long sequence) {
+ return StateCodec.writableLength(escapeSeenKey(device))
+ + StateCodec.writableLength(Long.toString(sequence))
+ + 2;
+ }
+
+ /// Evicts the least recently seen entries from one map until it is inside MAX_SEEN.
+ private static void trimTo(Map map) {
+ while (map.size() > MAX_SEEN) {
+ Iterator i = map.keySet().iterator();
+ if (!i.hasNext()) {
+ break;
+ }
+ // The eldest, and ALWAYS one: the sequence comparison this replaced could select
+ // nothing at all -- every value equal to Long.MAX_VALUE left its "lowest" null -- and
+ // then simply stopped enforcing the cap. Taking the front of the iteration order
+ // cannot fail to find a victim while the map is over size.
+ i.next();
+ i.remove();
+ }
+ }
+
+ /// Reads the persisted high-water marks, in the order they were written. Never null.
+ ///
+ /// LinkedHashMap, and that is the point of it. rememberSeen() writes durableSeen in ITS order,
+ /// which is least-recently-seen first, so the file carries the eviction order -- and a
+ /// HashMap here threw that away on the way back in. enable() then replayed an arbitrary order
+ /// into a map whose whole job is to evict the front, so after a restart with a full set of
+ /// marks the next new origin could evict a device the user is actively using instead of the
+ /// one quiet longest, and a delayed duplicate from the evicted device ran its side effects
+ /// again.
+ private static Map readSeen() {
+ Map out = new LinkedHashMap();
+ try {
+ if (!Display.isInitialized() || !Storage.getInstance().exists(PREF_SEEN)) {
+ return out;
+ }
+ Object stored = Storage.getInstance().readObject(PREF_SEEN);
+ String raw = stored instanceof String ? (String) stored : null;
+ if (raw == null || raw.length() == 0) {
+ return out;
+ }
+ // Split on UNESCAPED separators. The ids are not all ours: setDeviceId is public and
+ // a state arrives from whatever the relay was given, so an id may contain the very
+ // characters this format is delimited by. Unescaped, "phone|work" produced a sequence
+ // field that would not parse and a semicolon produced a whole second entry -- a mark
+ // written against an origin that never sent anything, which then suppresses that
+ // origin's real states for good.
+ int from = 0;
+ while (from <= raw.length()) {
+ int end = indexOfUnescaped(raw, ';', from);
+ String entry = end < 0 ? raw.substring(from) : raw.substring(from, end);
+ int bar = indexOfUnescaped(entry, '|', 0);
+ if (bar > 0 && bar < entry.length() - 1) {
+ try {
+ out.put(unescapeSeenKey(entry.substring(0, bar)),
+ Long.valueOf(Long.parseLong(entry.substring(bar + 1))));
+ } catch (NumberFormatException ignored) {
+ // A corrupt entry costs one duplicate delivery, never a launch.
+ }
+ }
+ if (end < 0) {
+ break;
+ }
+ from = end + 1;
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ return out;
+ }
+
+ /// The index of the first `c` that is not preceded by an escape, or -1.
+ private static int indexOfUnescaped(String s, char c, int from) {
+ boolean escaped = false;
+ for (int i = from; i < s.length(); i++) {
+ char at = s.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (at == '\\') {
+ escaped = true;
+ } else if (at == c) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /// Escapes the two delimiters, and the escape itself.
+ private static String escapeSeenKey(String key) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ if (c == '\\' || c == '|' || c == ';') {
+ sb.append('\\');
+ }
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
+ /// Reverses escapeSeenKey.
+ private static String unescapeSeenKey(String key) {
+ StringBuilder sb = new StringBuilder();
+ boolean escaped = false;
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ if (escaped) {
+ sb.append(c);
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ /// Writes the high-water marks, trimmed to MAX_SEEN.
+ ///
+ /// Called after a delivery is accepted, which is rare -- it takes another device publishing --
+ /// so this is not on any hot path.
+ private static void rememberSeen() {
+ // Serialized straight from the live map, which trimSeen() has already bounded. This used
+ // to copy and trim HERE, which bounded the preference and left the map itself growing for
+ // the life of the process -- and made every acknowledgement copy the whole thing and scan
+ // it back down to the cap, so a relay feeding many device ids cost memory and rising CPU
+ // at once. The cap belongs where entries go IN.
+ Map copy = durableSeen;
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry e : copy.entrySet()) {
+ if (sb.length() > 0) {
+ sb.append(';');
+ }
+ sb.append(escapeSeenKey(e.getKey())).append('|').append(e.getValue().longValue());
+ }
+ // ONLY the write is wrapped. Iterating a generic map compiles to checkcasts, and a
+ // catch(Throwable) around them is a handler ParparVM never runs -- its CHECKCAST expands
+ // to nothing, so a failed cast hands the wrong object to the next instruction and crashes
+ // natively instead. check-cast-semantics.sh refuses the shape, correctly: the only thing
+ // here that can actually fail is the preference write.
+ try {
+ if (!Storage.getInstance().writeObject(PREF_SEEN, sb.toString())) {
+ // The marks stay in memory, so this run still acts once. What is lost is the
+ // guarantee across a restart: an acknowledged state can be offered again and its
+ // side effects run a second time. Recoverable, unlike the alternative of dropping
+ // the state, and every later acknowledgement retries the write.
+ Log.p("Continuity: the delivery marks could not be stored; an acknowledged state "
+ + "may be offered again after a restart.");
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ private static long loadSequence() {
+ try {
+ if (!Display.isInitialized() || !Storage.getInstance().exists(PREF_SEQUENCE)) {
+ return 0;
+ }
+ Object o = Storage.getInstance().readObject(PREF_SEQUENCE);
+ // instanceof rather than a cast: a failed cast does not throw on the iOS virtual
+ // machine, it hands the wrong object to the next instruction.
+ return o instanceof Number ? ((Number) o).longValue() : 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;
+ // Delegated, not repeated. This used to install the callback itself under
+ // `b != null && enabled`, and both halves of that were wrong while refreshBridge() --
+ // ten lines below, doing the same job -- had them right.
+ //
+ // `b != null` skipped the case that needs it most: setBridge(null) hands resolution back
+ // to the PLATFORM, and the bridge it then resolves is a different object that has never
+ // been given a callback. Outbound calls kept working, so the seam looked healthy while
+ // every inbound continuation and synced-store notification went nowhere.
+ //
+ // `enabled` is not the right question either. A sync-only application installs the
+ // inbound seam through SyncedStore.addChangeListener and deliberately leaves continuity
+ // off, so storeCallbackInstalled is true while enabled is false -- and it got no callback
+ // at all.
+ refreshBridge();
+ }
+
+ /// 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. Installs the inbound seam WITHOUT turning continuity on. Application code uses
+ /// `com.codename1.continuity.sync.SyncedStore.addChangeListener`.
+ ///
+ /// `com.codename1.continuity.sync` is a package of its own precisely so that its cost is
+ /// earned separately, and `enable()` is not a cost the synced store asks for: it makes every
+ /// route change checkpoint, and a checkpoint advertises the app's navigation to the devices
+ /// around it over Handoff. Registering a store listener used to call it, so an application
+ /// that wanted a key/value store the user's devices share -- and nothing else -- was opted
+ /// into broadcasting its route stack.
+ ///
+ /// The store's own notification does not go through `enabled` (see Callback.syncedStoreChanged),
+ /// which is what lets the listener work with continuity still off.
+ public static void installSyncedStoreCallback() {
+ storeCallbackInstalled = true;
+ // Through the one installer, and NOT asking for held arrivals: a store listener is not
+ // consent to restore a route stack, so it installs the seam and leaves anything the port
+ // is holding for the enable() that may never come.
+ // Through the one installer, which is what stops this being called once per LISTENER.
+ // Every SyncedStore.addChangeListener() reached here, so a second listener gave the same
+ // bridge a second callback -- and ContinuityBridge documents that setCallback is called
+ // once, so a port that registers a native observer there keeps both and delivers every
+ // store change twice.
+ installCallback(false);
+ }
+
+ /// 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() {
+ // OR the store's own flag, not `enabled` alone. An application that only registers a
+ // SyncedStore listener deliberately leaves continuity off -- a key/value store is not
+ // consent to broadcast a route stack -- so testing `enabled` here meant the simulator's
+ // capability menu, which swaps the bridge and calls this, left the replacement with no
+ // callback at all and every later "Change the Synced Store" item silently did nothing.
+ // That is the documented sync-only workflow breaking on the first use of an unrelated
+ // menu item.
+ if (!enabled && !storeCallbackInstalled) {
+ return;
+ }
+ // Also through the one installer. It re-installs exactly when the bridge is a DIFFERENT
+ // object, which is what this method is for and is a sharper test than the unconditional
+ // call it replaces: a port that calls this without having swapped anything no longer
+ // stacks a second callback on the bridge it already gave one to.
+ //
+ // ENSURE, not ask-for-held. This method exists for a bridge the port has SWAPPED, and the
+ // instance guard is exactly that test -- a different object gets a seam, the same object
+ // does not get a second one. Asking for held arrivals here instead made two calls to this
+ // method install twice on one bridge, which is the accumulation the guard is for.
+ installCallback(false);
+ }
+
+ 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.
+ ///
+ /// A live relay worker is not waited for. Ending the session is enough: the worker carries the
+ /// session it started in, so its completion returns early and touches none of the flags reset
+ /// here.
+ static void reset() {
+ listeners.clear();
+ lastSeen.clear();
+ durableSeen.clear();
+ endRelaySession();
+ provider = null;
+ relay = null;
+ bridge = null;
+ bridgeOverridden = false;
+ enabled = false;
+ applicationHasChosen = false;
+ callbackInstalledOn = null;
+ formAtSessionEnd = null;
+ autoRestore = true;
+ flushScheduled = false;
+ title = null;
+ sequence = 0;
+ maxAge = 0;
+ deviceId = null;
+ parked = null;
+ shelved.clear();
+ handoffSizeReported = false;
+ dirty = false;
+ waitingForWindow = false;
+ applyingRestore = false;
+ storeCallbackInstalled = false;
+ lastCompleted = null;
+ lifecycle = 0;
+ heldFor = null;
+ clearingStack = false;
+ }
+
+ /// The store notification, as a constant rather than an anonymous class per callback.
+ ///
+ /// It captures nothing -- notifyChanged() is static -- so an inner class would hold its
+ /// enclosing Callback alive for no reason, which SpotBugs reports as
+ /// SIC_INNER_SHOULD_BE_STATIC_ANON.
+ private static final Runnable NOTIFY_STORE = new Runnable() {
+ @Override
+ public void run() {
+ com.codename1.continuity.sync.SyncedStore.notifyChanged();
+ }
+ };
+
+ /// The inbound seam handed to the port's bridge.
+ static final class Callback implements ContinuityCallback {
+ @Override
+ public boolean continuationReceived(String activityType, Map userInfo) {
+ // Called on the platform's thread, and answered from the activity type ALONE. The
+ // port needs a synchronous yes or no -- its answer decides whether the activity falls
+ // through to another handler -- and the type is a pure function of the package name,
+ // so the FIRST question here reads no framework state from a foreign thread.
+ if (activityType == null || !activityType.equals(getActivityType())) {
+ return false;
+ }
+ // The GENERATION, captured here, at the instant the arrival actually happened.
+ // Everything downstream queues at least once, and a logout already sitting on the
+ // event queue runs first -- so a generation read later is the generation AFTER the
+ // logout, every check passes, and the previous account's state is restored and
+ // persisted by a session that promised nothing from before it survives.
+ //
+ // Read from whatever thread the port called on. An int read from another thread
+ // yields a value the event thread wrote at some point and never a future one, so it
+ // can be stale-old but never stale-new: the worst it does is refuse an arrival that
+ // raced the logout exactly, which is the answer that side of the race wants.
+ final int arrivedIn = lifecycle;
+ if (!Display.isInitialized()) {
+ // No event thread to marshal to. Nothing else is running to race this, and
+ // deliver() holds the arrival for the EDT that is about to start.
+ return decide(userInfo, arrivedIn);
+ }
+ if (Display.getInstance().isEdt()) {
+ return decide(userInfo, arrivedIn);
+ }
+ // CLAIMED, unconditionally, and the framework then keeps it -- which is what makes
+ // that honest. Answering anything state-dependent from here means reading fields the
+ // event thread owns: `enabled` is not monotonic, and `applicationHasChosen` only
+ // looked safe. Its stale value is false, which meant declining, and a decline is
+ // recoverable ONLY while some later install re-offers the activity -- after enable()
+ // has installed the callback there is no such install, so the decline stranded the
+ // arrival with a port that had already been told to let go, or lost it outright with
+ // one that does not retain.
+ //
+ // So the framework takes it and holds it itself. decide() parks an arrival that comes
+ // before the application has chosen, and enable() drains that slot -- the retention
+ // that used to be borrowed from the port now lives where the state does.
+ // READ NOW, not when the decision runs. Queuing the caller's map keeps a reference
+ // to something the bridge owns: ContinuityCallback lets a port call from any thread
+ // and says nothing about the map outliving the call, so a port that recycles one
+ // buffer per arrival -- or clears it once this returns -- has the decision reading
+ // different contents than the ones it was handed. And this branch has ALREADY told
+ // the port the activity was claimed, so what gets restored is a different state, or
+ // none at all.
+ //
+ // fromMap() detaches it completely, which is why no copy helper is needed here:
+ // setPayload deep-copies, the routes are rebuilt into a new list, and everything else
+ // a payload may hold is immutable. What is queued is the framework's own object.
+ //
+ // The claim gets honest with it: a document that yields no state is declined rather
+ // than claimed, because nothing was taken.
+ final AppState arriving = StateCodec.fromMap(userInfo);
+ if (arriving == null) {
+ return false;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ decide(arriving, arrivedIn);
+ }
+ });
+ return true;
+ }
+
+ /// The decision itself, on the event thread -- or before there is one, where nothing else
+ /// is running to race it and deliver() parks the arrival for the EDT that is starting.
+ private boolean decide(Map userInfo, int arrivedIn) {
+ if (lifecycle != arrivedIn) {
+ // clear() or disable() ran between the arrival and this decision. Claimed, so the
+ // port lets go: the state belongs to a session that has ended and nothing is
+ // going to want it.
+ //
+ // NOT volatile, and deliberately. This framework is single threaded on the event
+ // thread; `lifecycle` is written there and nowhere else, and the value compared
+ // here was sampled ONCE when the activity arrived rather than re-read in a loop.
+ // A review asked for safe publication on the theory that a bridge calling from a
+ // long-lived background thread could keep observing a stale generation across a
+ // clear-then-enable. No port does that -- iOS is called per activity by the OS,
+ // which is itself a synchronizing hand-off; Android never reaches this seam; the
+ // simulator's bridge calls on the event thread -- and the direction of the error
+ // is the safe one either way: a stale generation DROPS an arrival, which the
+ // origin re-advertises and the relay still holds, while the opposite mistake
+ // restores an ended session's work into the account that replaced it. Making the
+ // field volatile would put cross-thread machinery into core to make an unlikely
+ // failure fail in the worse direction.
+ return true;
+ }
+ if (discardHeldArrival) {
+ // clear() is draining what the port held from before it. Claimed, which is what
+ // makes the port let go of it.
+ return true;
+ }
+ if (!enabled && applicationHasChosen) {
+ // The application has said what it wants and right now that is "off". Claimed,
+ // and therefore dropped: the port lets go of an activity that was handled, and
+ // nothing else answers to this application's own activity type.
+ return true;
+ }
+ AppState state = StateCodec.fromMap(userInfo);
+ if (state == null) {
+ return false;
+ }
+ return decide(state, arrivedIn);
+ }
+
+ /// The same decision once the document is a state of this framework's own.
+ ///
+ /// Re-asks the questions the caller already asked rather than trusting them: on the queued
+ /// path they were answered on another thread, and enable(), disable() or clear() can have
+ /// run in between.
+ private boolean decide(AppState state, int arrivedIn) {
+ if (lifecycle != arrivedIn) {
+ return true;
+ }
+ if (discardHeldArrival) {
+ return true;
+ }
+ if (!enabled && applicationHasChosen) {
+ return true;
+ }
+ if (!enabled) {
+ // NOTHING SAID YET, and the arrival is held HERE rather than left with the port.
+ //
+ // A store listener installs this seam without enabling continuity -- a key/value
+ // store is not consent to restore a route stack -- so a continuation that
+ // cold-launches the app can arrive with no one ready for it. That used to be a
+ // decline, and the port kept it and offered it again when a callback was next
+ // installed. Two things broke that: a decline reaches no port at all once this
+ // callback has claimed the arrival on another thread, and after enable() has
+ // installed the seam there is no later install to re-offer it.
+ //
+ // So the framework keeps it. enable() drains this slot, which is the same
+ // recovery the port used to provide, in the place that actually holds the state.
+ // False is still returned for a port that is holding one too: the two copies
+ // dedup on (origin, sequence) at admission.
+ placeOnOffer(state);
+ return false;
+ }
+ deliver(state);
+ return true;
+ }
+
+ @Override
+ public void syncedStoreChanged() {
+ // Arrives on the platform's thread, like continuationReceived. The listeners are
+ // application code and run on the event thread, as every other callback in the
+ // toolkit does.
+ if (!Display.isInitialized() || Display.getInstance().isEdt()) {
+ com.codename1.continuity.sync.SyncedStore.notifyChanged();
+ return;
+ }
+ Display.getInstance().callSerially(NOTIFY_STORE);
+ }
+ }
+}
diff --git a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java
new file mode 100644
index 00000000000..50ecd0eb4d2
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java
@@ -0,0 +1,75 @@
+/*
+ * 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.
+ ///
+ /// #### A false you will not come back from must acknowledge
+ ///
+ /// False keeps the state. It has to: the ordinary reason to return false is that you are
+ /// about to ask the user, and the arrival's only other copy may be on the relay -- so the
+ /// framework holds it, and holds this device's own checkpoints off the relay behind it, until
+ /// you say what happened. `Continuity.restore(AppState)` says accepted;
+ /// `Continuity.acknowledge(AppState)` says finished with.
+ ///
+ /// So a false that REJECTS -- the wrong account, older than the screen, anything you will
+ /// never restore -- must call `Continuity.acknowledge(AppState)`, or that hold never ends:
+ /// `Continuity.getRestorableState()` goes on offering the state you rejected, and this device
+ /// stops publishing to the relay for the rest of the process. The framework cannot tell a
+ /// rejection from a prompt that has not been answered yet, and guessing wrong in the other
+ /// direction loses work the user was about to accept.
+ ///
+ /// 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.
+ ///
+ /// If you handle it yourself and never call `restore`, call
+ /// `Continuity.acknowledge(AppState)` instead. Restoring records that the state was acted on
+ /// so it is not offered again after a relaunch; handling it silently does not, and without
+ /// the acknowledgement the relay's unchanged document is accepted on the next launch and your
+ /// side effects run a second time. It is not inferred from the false return, because false
+ /// also means "I am going to prompt" -- and marking that handled before the user answers
+ /// would lose the state if the process died first.
+ ///
+ /// #### 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..4ce05d293dc
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java
@@ -0,0 +1,252 @@
+/*
+ * 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.Log;
+import com.codename1.ui.Display;
+import com.codename1.io.rest.ErrorCodeHandler;
+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
+ ///
+ /// #### Changing accounts
+ ///
+ /// Install a NEW relay for the new account -- `Continuity.setRelay(StateRelay)` -- rather than
+ /// returning a different account's token from the same object. A publish that was authorised
+ /// for the previous account can still be between the framework's last check and this read
+ /// when the switch happens, and the framework cannot bind a token it is not allowed to read.
+ /// What it can recognise is an object that is no longer installed, which it then refuses; an
+ /// object that quietly starts answering for someone else looks identical to one that
+ /// refreshed its own session.
+ protected String getToken() {
+ return null;
+ }
+
+ @Override
+ 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()));
+ }
+ }
+
+ @Override
+ 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());
+ }
+
+ /// Adds the bearer token, refusing outright if this relay is no longer the installed one.
+ ///
+ /// The refusal is HERE, immediately before the token is read, because that is what makes it
+ /// worth anything. A worker that was started for one account and reaches the network after
+ /// the user has signed out and back in would otherwise send the first account's state
+ /// authenticated as the second: getToken() is read at each request, by design, so the same
+ /// relay object answers with whoever is signed in NOW.
+ ///
+ /// Continuity stops such a worker before it calls a relay at all. This is the second line for
+ /// the gap that check cannot cover -- it runs on the event thread, and the worker is not it.
+ /// Throwing rather than skipping quietly, so the framework records the publish as failed and
+ /// keeps owing it, and the state is republished once a relay is installed again.
+ ///
+ /// It asks whether this relay may SEND, not merely whether it is installed. A logout keeps
+ /// the same relay object in place on purpose, so identity alone said yes to a worker whose
+ /// account had signed out between its preflight and this line.
+ private RequestBuilder auth(RequestBuilder b) throws IOException {
+ if (!Continuity.mayRelaySend(this)) {
+ throw new IOException("This relay may not send: Continuity.setRelay() replaced it, "
+ + "or Continuity.clear() ended the session this request belongs to. Refusing "
+ + "the request rather than sending one account's state under another "
+ + "account's credentials.");
+ }
+ // SILENT, because these are housekeeping requests the user never asked for. A request
+ // builder sets failSilently only when an error-code handler is registered, and without it
+ // ConnectionRequest puts a Retry/Cancel dialog in front of the user for both a failure
+ // response and a connection exception. The 404 below is the DOCUMENTED answer for a relay
+ // that holds nothing yet, so a correctly implemented endpoint showed every user an error
+ // dialog on first run -- for the ordinary case, before this class could read the code and
+ // call it an empty relay.
+ //
+ // The handler itself does nothing on purpose: getAsString() builds its Response from the
+ // request's own code and body rather than from these callbacks, so publish() and fetch()
+ // still see 404, 204 and everything else exactly as before.
+ // NO REDIRECTS, because this request carries a bearer token. A redirect is followed
+ // with the same headers, so a 307 would hand the token and the state to whatever host
+ // the response names -- an `http://` one included, silently undoing the HTTPS the
+ // constructor insists on. A 302 or 303 is not safer, only different: it turns the POST
+ // into a GET, and the 2xx that follows makes publish() report a write that never
+ // happened.
+ //
+ // A relay that has moved should say so by being configured with its new URL, which is
+ // the application's decision to make and not a header's.
+ //
+ // WHERE THE PLATFORM ALLOWS IT. On iOS and the native macOS port that inherits its
+ // networking, NSURLSession follows redirects inside the native stack before the framework
+ // sees the response -- ConnectionRequest.setFollowRedirects says so in as many words, and
+ // the port answers "cn1.nativeRedirects" with true. This flag is not a promise there, and
+ // saying nothing about that would have been a false assurance in the one place it matters
+ // most. Reported once per process, because an endpoint that redirects has to be fixed at
+ // the endpoint: nothing in this class can stop it.
+ warnIfRedirectsCannotBeRefused();
+ RequestBuilder quiet = b.followRedirects(false).onErrorCodeString(SILENT);
+ String token = getToken();
+ return token == null || token.length() == 0 ? quiet : quiet.bearer(token);
+ }
+
+ /// Says so, once, when the platform will follow redirects whatever this class asks.
+ ///
+ /// Not a workaround -- there is none from here. The redirect is taken inside the native
+ /// networking stack, so this code never sees the response that ordered it and cannot inspect
+ /// where the request actually went. What it can do is stop the guarantee from being silent,
+ /// so an endpoint that redirects is a thing somebody knows to fix rather than a token that
+ /// quietly went somewhere else.
+ private static void warnIfRedirectsCannotBeRefused() {
+ if (redirectWarningSaid) {
+ return;
+ }
+ redirectWarningSaid = true;
+ try {
+ if (!"true".equals(Display.getInstance().getProperty("cn1.nativeRedirects", "false"))) {
+ return;
+ }
+ } catch (Throwable t) {
+ Log.e(t);
+ return;
+ }
+ Log.p("Continuity: this platform follows HTTP redirects inside its native networking, so "
+ + "the relay cannot refuse them. A redirect from the relay endpoint would carry "
+ + "the bearer token to wherever it points. Make sure the endpoint answers "
+ + "directly rather than redirecting.");
+ }
+
+ /// So the warning above is said once rather than on every request.
+ private static boolean redirectWarningSaid;
+
+ /// Registered on every request purely to make it silent. See auth().
+ ///
+ /// A constant rather than an anonymous class per request: it captures nothing, and an inner
+ /// class would hold its enclosing relay alive for no reason -- which SpotBugs reports as
+ /// SIC_INNER_SHOULD_BE_STATIC_ANON.
+ private static final ErrorCodeHandler SILENT = new ErrorCodeHandler() {
+ @Override
+ public void onError(Response errorData) {
+ // Deliberately nothing. The caller reads the response code and decides there.
+ }
+ };
+}
diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java
new file mode 100644
index 00000000000..5af0fef5ee9
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java
@@ -0,0 +1,1092 @@
+/*
+ * 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.Log;
+import com.codename1.io.JSONWriter;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+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";
+
+ /// Says that this document's payload values carry type tags.
+ ///
+ /// The decision has to be made once for the DOCUMENT, not guessed per value. decode() used to
+ /// ask of every string whether it looked tagged, so an untagged payload -- what a
+ /// hand-written endpoint or an older build produces, and which this codec deliberately
+ /// accepts -- had any ordinary string of the form "i:5" or "s:note" silently reinterpreted:
+ /// the first became an Integer, the second lost its prefix. That is application data changed
+ /// in transit, and no per-string rule can tell the two apart, because "i:5" is a perfectly
+ /// good string.
+ ///
+ /// A document written by this codec says so. One without the marker is read exactly as it
+ /// arrived.
+ private static final String KEY_ENCODING = "enc";
+
+ /// The only encoding this codec writes. Absent means untagged.
+ private static final String ENCODING_TAGGED = "1";
+ 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";
+
+ /// The fields this codec writes. A document carrying none of them is not a state, whatever
+ /// else it contains.
+ private static final String[] KNOWN_KEYS = {
+ KEY_ROUTES, KEY_PAYLOAD, KEY_DEVICE, KEY_TITLE, KEY_SEQUENCE, KEY_TIMESTAMP,
+ };
+
+ 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, encode(state.payloadRef()));
+ m.put(KEY_ENCODING, ENCODING_TAGGED);
+ 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;
+ }
+ // Something recognizable has to be in there. An empty object, or an unrelated one, used
+ // to come back as a default AppState -- which the continuation callback then CLAIMED and
+ // delivered, so a relay answering "{}" ran the application's listeners and could put a
+ // "continue what you were doing?" prompt in front of the user over nothing at all.
+ boolean recognized = false;
+ for (String known : KNOWN_KEYS) {
+ if (m.containsKey(known)) {
+ recognized = true;
+ break;
+ }
+ }
+ if (!recognized) {
+ return null;
+ }
+ try {
+ // HERE, not only in fromJson. A platform continuation -- an NSUserActivity, or
+ // anything a custom bridge hands over -- reaches this method DIRECTLY, so every
+ // schema check added for the relay wire was missing from the other way in. A
+ // continuation with a good origin and sequence but "routes" as a string dropped the
+ // field, produced an empty state, and admission consumed that as a tombstone and
+ // advanced the origin's durable high-water mark: the same harm, on the path that
+ // never touches JSON.
+ //
+ // NULL rather than an exception, because this is the answer every caller of this
+ // method already handles and the continuation callback is not a place to throw.
+ // fromJson keeps its own throwing check so a bad fetch is reported as a failed read
+ // rather than as an empty relay.
+ requireKnownTypes(m);
+ } catch (IOException malformed) {
+ Log.e(malformed);
+ return null;
+ }
+ AppState state = new AppState();
+ Object routes = m.get(KEY_ROUTES);
+ if (routes instanceof List) {
+ List paths = new ArrayList();
+ for (Object path : (List>) routes) {
+ if (path instanceof String) {
+ paths.add((String) path);
+ }
+ }
+ // Unchecked, exactly as the payload below is: this document came from another
+ // device, and one route past this device's stored-string limit used to throw out of
+ // fromJson -- which the relay reads as a failed fetch, and the document never
+ // changes, so this device stopped publishing for good.
+ state.setRoutesUnchecked(paths);
+ }
+ // Whether the values are tagged is the DOCUMENT's answer, not a guess made per string.
+ // See KEY_ENCODING: without it, "i:5" is just a string and stays one.
+ Object encoding = m.get(KEY_ENCODING);
+ boolean tagged = encoding instanceof String && ENCODING_TAGGED.equals(encoding);
+ Object payload = m.get(KEY_PAYLOAD);
+ if (payload instanceof Map) {
+ Map copy = new HashMap();
+ Map, ?> read = (Map, ?>) payload;
+ for (Map.Entry, ?> entry : read.entrySet()) {
+ if (entry.getKey() instanceof String) {
+ copy.put((String) entry.getKey(),
+ tagged ? decode(entry.getValue()) : entry.getValue());
+ }
+ }
+ // Validated, with the SAME check a locally built payload gets -- there is one
+ // definition of what a payload may hold and this is it. AppState documents the
+ // restriction and setPayload() enforced it, while a payload arriving from another
+ // device went in unchecked: a null nested in a list survived to the listeners and the
+ // provider, was acknowledged, and then met the iOS property-list sanitizer, which
+ // drops it and shifts every index after it. The application's data changes shape
+ // between one device and the next, silently.
+ //
+ // NULL rather than an exception, which is the same answer the field-type checks above
+ // give and the reason the old comment here gave for skipping this: a remote mistake
+ // must not become an exception on this device. It does not -- it becomes a failed
+ // read, so the document stays on the relay for a build that can use it.
+ try {
+ state.setPayload(copy);
+ } catch (IllegalArgumentException malformed) {
+ Log.e(malformed);
+ return null;
+ }
+ }
+ Object device = m.get(KEY_DEVICE);
+ if (device instanceof String) {
+ state.setDeviceIdUnchecked((String) device);
+ }
+ Object title = m.get(KEY_TITLE);
+ if (title instanceof String) {
+ String label = (String) title;
+ if (exceedsWritableLength(label)) {
+ // DROPPED, and only this field. A title is the label a receiving device may show
+ // before the user accepts -- losing it costs a nicety, while refusing the
+ // document costs the user their work. Carrying it was the worst of the three:
+ // commit() persists it through externalize(), which throws on the oversized
+ // string every time, so the arrival is parked, re-applied on every retry and
+ // holds every relay publication behind it -- after the provider and the route
+ // rebuild have already run.
+ //
+ // Nothing this framework sends produces one: setTitle() refuses it at the call.
+ Log.p("Continuity: dropping a continuation title longer than "
+ + MAX_STRING_BYTES + " bytes. The state itself is kept.");
+ } else {
+ state.setTitleUnchecked(label);
+ }
+ }
+ 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;
+ }
+ if (!isValidJsonObject(json)) {
+ // JSONParser does NOT throw on a malformed document: it logs the failure, closes the
+ // reader, and returns whatever partial map it had built. So a truncated relay response
+ // came back as a valid-looking state, and the shape it takes is the worst one -- a
+ // document cut off after "device" and "seq" has no routes and no payload, which is an
+ // EMPTY state, which this framework reads as a tombstone. The origin is then recorded
+ // as having cleared its work, durably, and fetch() reports a SUCCESSFUL read, which
+ // releases a queued POST over the relay's real document.
+ //
+ // So the check is here rather than left to the parser. Truncation is the corruption
+ // that actually happens on a network, and it is exactly what a structural scan
+ // catches -- and so is a bad token, which a structural scan alone let through:
+ // {"device":"d","seq":"2","payload":tru} is balanced, quoted, and invalid.
+ throw new IOException("The continuity relay returned a document that is not a "
+ + "valid JSON object. Treated as a failed read rather than as an empty "
+ + "relay, because a truncated document is indistinguishable from one that "
+ + "says the other device has nothing.");
+ }
+ // Parsed with NULLS KEPT. The convenience parser drops a null-valued field before
+ // anything can look at it, so {"payload":null} reached the checks below as an ABSENT
+ // payload -- and absent routes plus an absent payload is an empty state, which this
+ // framework reads as a tombstone. A field that is present and null has to stay
+ // distinguishable from one that was never sent.
+ JSONParser parser = new JSONParser();
+ parser.setIncludeNullsInstance(true);
+ // Booleans as BOOLEANS. The parser defaults to answering a raw JSON true or false with
+ // the strings "true" and "false", which is fine for the tagged form this codec writes --
+ // "b:true" is a string either way -- and wrong for an untagged compatibility document
+ // from a hand-written endpoint: the payload reached the listeners and the provider with
+ // Strings where the sender wrote booleans, passed validation because a String is a
+ // representable type, and was acknowledged.
+ parser.setUseBooleanInstance(true);
+ Map parsed = parser.parseJSON(new java.io.StringReader(json));
+ requireKnownTypes(parsed);
+ AppState state = fromMap(parsed);
+ if (state == null && parsed != null && !parsed.isEmpty()) {
+ // A document with CONTENT that this build recognises none of. fromMap answers null
+ // for it, and null means "the relay holds nothing" to the code that reads a fetch --
+ // so the publisher is released and a local checkpoint overwrites a document this
+ // device never managed to read. `{"error":"temporarily unavailable"}` returned with a
+ // 2xx is the shape that does it.
+ //
+ // NON-EMPTY is the whole condition. A bare `{}` is a plausible way for an endpoint to
+ // say it holds nothing, alongside the 404 and the empty body this class documents,
+ // and refusing that would leave such an endpoint unable to publish anything, ever.
+ // An object carrying fields none of which are ours is a different thing: something is
+ // there and this build cannot read it.
+ throw new IOException("The continuity relay returned an object with no fields this "
+ + "build recognises. Treated as a failed read rather than as an empty relay, "
+ + "because something is stored there and publishing over it would destroy "
+ + "work this device could not read.");
+ }
+ return state;
+ }
+
+ /// Refuses a document whose known fields carry the wrong kind of value.
+ ///
+ /// Valid syntax is not a valid STATE. `{"device":"other","seq":"10","payload":[]}` parses
+ /// cleanly, and fromMap then ignores the array where a payload belongs -- leaving routes and
+ /// payload both empty, which is an EMPTY state, which the framework reads as a tombstone. So
+ /// a relay serving one wrong type has the origin recorded as having cleared its work, marked
+ /// durably, and a queued publish released over the server's document. The same shape with
+ /// valid routes restores and acknowledges a state whose payload was silently dropped.
+ ///
+ /// Only fields that are PRESENT are checked, and only ones this codec knows. An absent field
+ /// is an older or smaller document, which is legitimate; an unknown field belongs to a
+ /// sender that knows something this build does not, and ignoring it is how the format stays
+ /// extensible. What is refused is a known field that cannot mean what it says.
+ private static void requireKnownTypes(Map m) throws IOException {
+ if (m == null) {
+ return;
+ }
+ requireType(m, KEY_ROUTES, List.class, "an array of route strings");
+ requireRouteStrings(m);
+ requireType(m, KEY_PAYLOAD, Map.class, "an object");
+ requireType(m, KEY_ENCODING, String.class, "a string");
+ Object encoding = m.get(KEY_ENCODING);
+ if (encoding != null && !ENCODING_TAGGED.equals(encoding)) {
+ // An unknown ENCODING is not an unknown field. A field this codec does not know is
+ // ignored on purpose -- that is how the format stays extensible and how a newer
+ // sender goes on talking to this build. An encoding marker changes how the fields it
+ // DOES know must be read, so falling back to "untagged" handed the provider every
+ // encoded scalar as a raw string, and the state was then persisted and acknowledged:
+ // the origin's high-water mark advanced, so the correctly encoded document was never
+ // offered again, not even after the receiving app was upgraded to understand it.
+ //
+ // A failed read instead, which is retryable and leaves the document on the relay.
+ throw new IOException("The continuity relay returned a document encoded as \""
+ + encoding + "\", which this build cannot read -- it understands \""
+ + ENCODING_TAGGED + "\" and documents with no encoding marker at all. "
+ + "Treated as a failed read so the document stays where a build that "
+ + "understands it can still use it.");
+ }
+ requireType(m, KEY_DEVICE, String.class, "a string");
+ Object device = m.get(KEY_DEVICE);
+ if (device instanceof String && exceedsWritableLength((String) device)) {
+ // REFUSED, where an oversized title is dropped. The two are not alike: a title is a
+ // label a receiving device may show, and losing it costs a nicety, while the origin
+ // id is the key every mark and every dedup decision is made against -- a state
+ // without one is refused by admit() anyway, so dropping it would only move the
+ // refusal somewhere less clear.
+ //
+ // Carrying it is the answer that does damage: commit() writes the id through
+ // Util.writeUTF, which throws on it every time, so the arrival is parked, re-applied
+ // on every retry and holds every relay publication behind it -- after the provider
+ // and the route rebuild have already run.
+ throw new IOException("The continuity relay returned a document whose device id is "
+ + "longer than " + MAX_STRING_BYTES + " bytes of modified UTF-8, which is "
+ + "more than a stored checkpoint can hold. Treated as a failed read, so the "
+ + "document stays where a sender that fixes it can replace it.");
+ }
+ requireType(m, KEY_TITLE, String.class, "a string");
+ requireNumberLike(m, KEY_SEQUENCE);
+ requireNumberLike(m, KEY_TIMESTAMP);
+ // And a timestamp that is not in the PAST-or-absent shape the rest of the framework reads
+ // it as. Zero is the documented "this state carries no time", and isTooOld() reads
+ // anything not positive that way -- so a negative one is a state that can never expire,
+ // whatever maxAge the application configured. An expired checkout or a released booking
+ // hold would go on being restorable for the life of the install, which is the one thing
+ // maxAge exists to stop.
+ //
+ // Refused HERE rather than clamped, for the reason the sequence check gives: a value this
+ // codec silently repaired would differ from what the sender believes it sent, and the two
+ // sides then disagree about a state neither can see. It is also where the arithmetic
+ // stays safe -- Long.MIN_VALUE would overflow a subtraction, and the only reason it does
+ // not today is the positive-guard that this same malformed value hides behind.
+ if (asLong(m.get(KEY_TIMESTAMP)) < 0) {
+ throw new IOException("The continuity relay returned a document whose \""
+ + KEY_TIMESTAMP + "\" is negative. Zero means a state carries no time and "
+ + "anything below it means nothing at all, so it is refused rather than read "
+ + "as a state that can never expire.");
+ }
+ }
+
+ /// Every ELEMENT of the route array, not just the array.
+ ///
+ /// Checking the container alone left {"routes":[1]} passing: the list is a list, the loop
+ /// that reads it drops the element it cannot use, and what comes out has no routes and no
+ /// payload -- an empty state, which this framework reads as a tombstone. The same door as a
+ /// wrong payload type and a null field, one level further in.
+ private static void requireRouteStrings(Map m) throws IOException {
+ Object routes = m.get(KEY_ROUTES);
+ if (!(routes instanceof List)) {
+ return;
+ }
+ List> list = (List>) routes;
+ for (int i = 0; i < list.size(); i++) {
+ Object path = list.get(i);
+ if (path instanceof String) {
+ if (((String) path).length() > 0) {
+ continue;
+ }
+ // An EMPTY string is a string, and it is not a route. It survives every check
+ // above, so the state is not empty and is not read as a tombstone -- and then
+ // restoreStack() skips the path, rebuilds nothing, and the arrival is classified
+ // as an attempt that failed: parked for ever, re-offered on every launch, with
+ // every relay publication held behind it.
+ //
+ // Refused rather than filtered, for the reason the refusal below gives: dropping
+ // the only route turns the document into an empty state, which means something
+ // else entirely. Nothing this framework writes produces one -- setRoutes() skips
+ // empty paths -- so no legitimate sender is refused.
+ throw new IOException("The continuity relay returned a document whose route at "
+ + "index " + i + " is an empty string. It is not a path that can be "
+ + "rebuilt, and keeping it would leave an arrival that can never be "
+ + "applied and never be let go of.");
+ }
+ throw new IOException("The continuity relay returned a document whose route at index "
+ + i + " is not a string. Dropping it would leave a state with fewer routes "
+ + "than the sender meant, and dropping the only one would make it an empty "
+ + "state -- which means the sending device cleared its work.");
+ }
+ }
+
+ private static void requireType(Map m, String key, Class> type, String what)
+ throws IOException {
+ if (!m.containsKey(key)) {
+ return;
+ }
+ Object value = m.get(key);
+ if (value == null) {
+ throw new IOException("The continuity relay returned a document whose \"" + key
+ + "\" is null. A sender that means \"absent\" leaves the key out; a key that "
+ + "is present and empty is a document this codec cannot read, and reading it "
+ + "as absent would make it an empty state -- which means the sending device "
+ + "cleared its work.");
+ }
+ if (type.isInstance(value)) {
+ return;
+ }
+ throw new IOException("The continuity relay returned a document whose \"" + key
+ + "\" is not " + what + ". Treated as a failed read rather than as a state, "
+ + "because a field this codec cannot use is indistinguishable from one that is "
+ + "absent -- and an absent payload and routes make an empty state, which means "
+ + "the sending device cleared its work.");
+ }
+
+ /// seq and ts, which this codec writes as strings and older senders may write as numbers.
+ private static void requireNumberLike(Map m, String key) throws IOException {
+ if (!m.containsKey(key)) {
+ return;
+ }
+ Object value = m.get(key);
+ if (value instanceof Number) {
+ // NOT just "a number". JSONParser answers a bare 1e100 with a Double, and asLong()
+ // then converts it to Long.MAX_VALUE -- so one such document raises this origin's
+ // durable high-water mark to the largest value there is, and every ordinary sequence
+ // it sends afterwards is refused as already seen, for the life of the installation.
+ // A fractional value is refused for the same reason in miniature: 5.7 becomes 5, and
+ // the sender's 5 is then indistinguishable from it.
+ if (value instanceof Long || value instanceof Integer
+ || value instanceof Short || value instanceof Byte) {
+ // Already a whole number inside the range, by its own type. Routing these
+ // through a double would REJECT a perfectly good Long near the top of the
+ // range, because that is precisely the region a double cannot represent.
+ // JSONParser answers with Long rather than Double when useLongs is on, which
+ // any code in the process can turn on through the static setter.
+ return;
+ }
+ double d = ((Number) value).doubleValue();
+ // The upper bound is >= and is written against MIN_VALUE, which is the only one of
+ // the two a double holds exactly. Long.MAX_VALUE is not representable: (double)
+ // Long.MAX_VALUE rounds UP to 2^63, so a sender's 9223372036854775808 -- one past
+ // the range -- compares equal to it and a "> (double) Long.MAX_VALUE" test let it
+ // through, to be clamped back to Long.MAX_VALUE by the conversion. -(double)
+ // Long.MIN_VALUE is exactly 2^63, and a double is convertible to a long precisely
+ // when it is at least -2^63 and strictly below 2^63.
+ //
+ // This does refuse a sender that writes Long.MAX_VALUE itself as a bare NUMBER,
+ // and there is no way not to: no double distinguishes it from the value one past
+ // the range. This codec writes seq as a STRING for exactly that reason, and the
+ // string path parses it exactly.
+ if (Double.isNaN(d) || Double.isInfinite(d)
+ || d != Math.floor(d)
+ || d < (double) Long.MIN_VALUE || d >= -((double) Long.MIN_VALUE)) {
+ throw new IOException("The continuity relay returned a document whose \"" + key
+ + "\" is " + value + ", which is not a whole number this device can "
+ + "hold. Accepting it would clamp the value to the largest sequence "
+ + "there is and refuse every later state from that device as already "
+ + "seen.");
+ }
+ return;
+ }
+ if (value instanceof String) {
+ try {
+ Long.parseLong(((String) value).trim());
+ return;
+ } catch (NumberFormatException err) {
+ // Falls through to the refusal below: a string that is not a number cannot be a
+ // sequence, and asLong() would silently answer 0 -- which is a valid-looking
+ // sequence that every later state supersedes.
+ Log.e(err);
+ }
+ }
+ throw new IOException("The continuity relay returned a document whose \"" + key
+ + "\" is not a number. Read as zero it would be a sequence every later state "
+ + "supersedes, so it is refused instead.");
+ }
+
+ /// Whether `json` is ONE syntactically valid JSON object and nothing else.
+ ///
+ /// A real grammar check, because a structural one was not enough. Counting braces and closing
+ /// strings catches a document cut in half, and lets
+ /// `{"device":"d","seq":"2","payload":tru}` through -- balanced, quoted, and invalid. The
+ /// parser then logs the bad token and returns the map it had built up to that point, which is
+ /// a partial state with the same consequences as a truncated one.
+ ///
+ /// Nothing is built here and no value is interpreted: this answers only "is the whole of this
+ /// document well formed", so it cannot disagree with the parser about what anything MEANS.
+ /// The alternative was asking the parser, and it has no way to say -- its exception handler
+ /// logs, closes the reader, and returns the partial result.
+ static boolean isValidJsonObject(String json) {
+ String t = json.trim();
+ if (t.length() < 2 || t.charAt(0) != '{') {
+ return false;
+ }
+ int[] at = new int[1];
+ if (!scanValue(t, at)) {
+ return false;
+ }
+ skipWhitespace(t, at);
+ // Trailing content is not a second document, it is a broken one.
+ return at[0] == t.length();
+ }
+
+ private static void skipWhitespace(String s, int[] at) {
+ while (at[0] < s.length()) {
+ char c = s.charAt(at[0]);
+ if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
+ return;
+ }
+ at[0]++;
+ }
+ }
+
+ private static boolean scanValue(String s, int[] at) {
+ skipWhitespace(s, at);
+ if (at[0] >= s.length()) {
+ return false;
+ }
+ char c = s.charAt(at[0]);
+ if (c == '{') {
+ return scanObject(s, at);
+ }
+ if (c == '[') {
+ return scanArray(s, at);
+ }
+ if (c == '"') {
+ return scanString(s, at);
+ }
+ if (c == 't') {
+ return scanLiteral(s, at, "true");
+ }
+ if (c == 'f') {
+ return scanLiteral(s, at, "false");
+ }
+ if (c == 'n') {
+ return scanLiteral(s, at, "null");
+ }
+ return scanNumber(s, at);
+ }
+
+ private static boolean scanObject(String s, int[] at) {
+ at[0]++;
+ skipWhitespace(s, at);
+ if (at[0] < s.length() && s.charAt(at[0]) == '}') {
+ at[0]++;
+ return true;
+ }
+ for (;;) {
+ skipWhitespace(s, at);
+ if (at[0] >= s.length() || s.charAt(at[0]) != '"' || !scanString(s, at)) {
+ return false;
+ }
+ skipWhitespace(s, at);
+ if (at[0] >= s.length() || s.charAt(at[0]) != ':') {
+ return false;
+ }
+ at[0]++;
+ if (!scanValue(s, at)) {
+ return false;
+ }
+ skipWhitespace(s, at);
+ if (at[0] >= s.length()) {
+ return false;
+ }
+ char c = s.charAt(at[0]);
+ at[0]++;
+ if (c == '}') {
+ return true;
+ }
+ if (c != ',') {
+ return false;
+ }
+ }
+ }
+
+ private static boolean scanArray(String s, int[] at) {
+ at[0]++;
+ skipWhitespace(s, at);
+ if (at[0] < s.length() && s.charAt(at[0]) == ']') {
+ at[0]++;
+ return true;
+ }
+ for (;;) {
+ if (!scanValue(s, at)) {
+ return false;
+ }
+ skipWhitespace(s, at);
+ if (at[0] >= s.length()) {
+ return false;
+ }
+ char c = s.charAt(at[0]);
+ at[0]++;
+ if (c == ']') {
+ return true;
+ }
+ if (c != ',') {
+ return false;
+ }
+ }
+ }
+
+ private static boolean scanString(String s, int[] at) {
+ at[0]++;
+ while (at[0] < s.length()) {
+ char c = s.charAt(at[0]);
+ at[0]++;
+ if (c == '"') {
+ return true;
+ }
+ if (c < ' ') {
+ // JSON forbids an unescaped character below U+0020 inside a string, and this
+ // check accepted every one of them. It is not a formality: the framework parser
+ // appends a raw control byte to whatever it is building rather than stopping, so
+ // a document carrying a literal newline INSIDE a key -- "payload" -- passed
+ // as valid and came out with a key that is not "payload". The field is then
+ // unknown and dropped, and a state with no payload and no routes is a tombstone:
+ // the sending device is read as having cleared its work.
+ //
+ // No conformant encoder emits one, so nothing legitimate is refused by this.
+ return false;
+ }
+ if (c == '\\') {
+ if (at[0] >= s.length()) {
+ return false;
+ }
+ char e = s.charAt(at[0]);
+ at[0]++;
+ if (e == 'u') {
+ if (at[0] + 4 > s.length()) {
+ return false;
+ }
+ for (int i = 0; i < 4; i++) {
+ if (hexValue(s.charAt(at[0] + i)) < 0) {
+ return false;
+ }
+ }
+ at[0] += 4;
+ } else if ("\"\\/bfnrt".indexOf(e) < 0) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static int hexValue(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+ return -1;
+ }
+
+ private static boolean scanLiteral(String s, int[] at, String literal) {
+ if (!s.startsWith(literal, at[0])) {
+ return false;
+ }
+ at[0] += literal.length();
+ return true;
+ }
+
+ private static boolean scanNumber(String s, int[] at) {
+ int start = at[0];
+ if (at[0] < s.length() && s.charAt(at[0]) == '-') {
+ at[0]++;
+ }
+ // JSON's integer rule exactly: a single 0, or a non-zero digit and any digits after it.
+ // A permissive loop accepted "01", which is not JSON -- and being laxer than the grammar
+ // is the whole failure this validator exists to correct, so it does not get to make its
+ // own small version of it.
+ if (at[0] >= s.length()) {
+ return false;
+ }
+ char first = s.charAt(at[0]);
+ if (first == '0') {
+ at[0]++;
+ } else if (first >= '1' && first <= '9') {
+ while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') {
+ at[0]++;
+ }
+ } else {
+ return false;
+ }
+ if (at[0] < s.length() && s.charAt(at[0]) == '.') {
+ at[0]++;
+ int frac = 0;
+ while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') {
+ at[0]++;
+ frac++;
+ }
+ if (frac == 0) {
+ return false;
+ }
+ }
+ if (at[0] < s.length() && (s.charAt(at[0]) == 'e' || s.charAt(at[0]) == 'E')) {
+ at[0]++;
+ if (at[0] < s.length() && (s.charAt(at[0]) == '+' || s.charAt(at[0]) == '-')) {
+ at[0]++;
+ }
+ int exp = 0;
+ while (at[0] < s.length() && s.charAt(at[0]) >= '0' && s.charAt(at[0]) <= '9') {
+ at[0]++;
+ exp++;
+ }
+ if (exp == 0) {
+ return false;
+ }
+ }
+ return at[0] > start;
+ }
+
+ /// 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 (Map.Entry entry : payload.entrySet()) {
+ if (entry.getKey() == null) {
+ throw new IllegalArgumentException("A continuity payload cannot have a null key.");
+ }
+ // Keys go through the same writeUTF as values, so an oversized one loses the
+ // checkpoint just as quietly.
+ requireWritable(entry.getKey(), entry.getKey());
+ 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();
+ }
+
+ /// Renders a payload value so its Java type survives every transport.
+ ///
+ /// Neither destination format preserves the types this payload admits. `JSONParser` reads
+ /// every JSON number back as a `Double` -- so an `Integer` returns as `3.0`, and a `Long`
+ /// past 2^53 comes back a different number -- and it reads `true` back as the *string*
+ /// `"true"`. A property list is kinder but not identical. The result was an application
+ /// casting a value to the type it stored and getting a `ClassCastException` on Android and
+ /// the desktop, and on iOS something worse: ParparVM does not throw for a failed cast, so
+ /// the wrong object is handed to the next instruction.
+ ///
+ /// So every scalar crosses as a tagged string and is put back together on arrival. Strings
+ /// are tagged too, which is what stops an application's own `"i:5"` from being read as an
+ /// integer. Lists and maps stay themselves -- both formats carry those natively -- and their
+ /// contents are encoded element by element.
+ private static Object encodeValue(Object value) {
+ if (value instanceof String) {
+ return "s:" + value;
+ }
+ if (value instanceof Integer) {
+ return "i:" + value;
+ }
+ if (value instanceof Long) {
+ return "l:" + value;
+ }
+ if (value instanceof Double) {
+ return "d:" + value;
+ }
+ if (value instanceof Boolean) {
+ return "b:" + value;
+ }
+ if (value instanceof List) {
+ List> in = (List>) value;
+ List out = new ArrayList();
+ for (Object element : in) {
+ out.add(encodeValue(element));
+ }
+ return out;
+ }
+ if (value instanceof Map) {
+ return encode(castToStringKeyed((Map, ?>) value));
+ }
+ // Unreachable for a payload that went through requireRepresentable, which is every
+ // payload this framework produces. A hand-built map handed straight to toMap reaches
+ // here, and its own toString is a better answer than dropping the entry.
+ return "s:" + String.valueOf(value);
+ }
+
+ private static Map encode(Map payload) {
+ Map out = new HashMap();
+ if (payload == null) {
+ return out;
+ }
+ for (Map.Entry e : payload.entrySet()) {
+ out.put(e.getKey(), encodeValue(e.getValue()));
+ }
+ return out;
+ }
+
+ /// Rebuilds a value `encodeValue` wrote.
+ ///
+ /// Only ever called for a document that DECLARED its values tagged, through KEY_ENCODING. A
+ /// document without that marker -- a hand-written endpoint, or a build older than the
+ /// tagging -- is passed through untouched by the caller, because there is no way to tell an
+ /// encoded "i:5" from a string whose value happens to be "i:5", and guessing corrupts the
+ /// second to rescue the first.
+ ///
+ /// A value that is untagged INSIDE a tagged document is still passed through: nested
+ /// containers are walked, and anything unrecognized is more useful untyped than discarded.
+ private static Object decode(Object value) {
+ if (value instanceof List) {
+ List> in = (List>) value;
+ List out = new ArrayList();
+ for (Object element : in) {
+ out.add(decode(element));
+ }
+ return out;
+ }
+ if (value instanceof Map) {
+ Map, ?> in = (Map, ?>) value;
+ Map out = new HashMap();
+ for (Map.Entry, ?> e : in.entrySet()) {
+ if (e.getKey() instanceof String) {
+ out.put((String) e.getKey(), decode(e.getValue()));
+ }
+ }
+ return out;
+ }
+ if (!(value instanceof String)) {
+ return value;
+ }
+ String text = (String) value;
+ if (text.length() < 2 || text.charAt(1) != ':') {
+ return text;
+ }
+ String body = text.substring(2);
+ char tag = text.charAt(0);
+ try {
+ if (tag == 's') {
+ return body;
+ }
+ // parseX rather than valueOf(String). Core is compiled a second time against
+ // Ports/CLDC11 and translated against vm/JavaAPI, and neither carries the
+ // String-taking valueOf overloads -- only valueOf(primitive). The Maven build accepts
+ // them against the full JDK, so the mistake only appears in the Ant leg.
+ if (tag == 'i') {
+ return Integer.valueOf(Integer.parseInt(body));
+ }
+ if (tag == 'l') {
+ return Long.valueOf(Long.parseLong(body));
+ }
+ if (tag == 'd') {
+ return Double.valueOf(Double.parseDouble(body));
+ }
+ if (tag == 'b') {
+ // The EXACT bodies the encoder writes, and nothing else. Boolean.valueOf answers
+ // false for every string that is not "true", so "b:unknown" arrived as a
+ // confident false: application data changed in transit, restored, and
+ // acknowledged, with nothing said. Every other tag here already preserves a body
+ // it cannot parse -- the number tags do it through the catch below -- and this
+ // one now falls through to the same answer.
+ if ("true".equals(body)) {
+ return Boolean.TRUE;
+ }
+ if ("false".equals(body)) {
+ return Boolean.FALSE;
+ }
+ }
+ } catch (NumberFormatException malformed) {
+ // A tag whose body will not parse came from somewhere this build does not control.
+ // The text is the honest answer; throwing would lose the whole state over one key.
+ return text;
+ }
+ return text;
+ }
+
+ private static Map castToStringKeyed(Map, ?> in) {
+ Map out = new HashMap();
+ for (Map.Entry, ?> e : in.entrySet()) {
+ if (e.getKey() instanceof String) {
+ out.put((String) e.getKey(), e.getValue());
+ }
+ }
+ return out;
+ }
+
+ /// The most modified-UTF-8 bytes a single string in a payload may occupy.
+ ///
+ /// `Util.writeObject` writes every String with `DataOutputStream.writeUTF`, which cannot
+ /// encode more than this and throws when asked to. `Continuity.persist()` logs that failure
+ /// and carries on, so an oversized payload produced a checkpoint that LOOKED successful and
+ /// simply was not there after the process died -- the one thing state restoration exists to
+ /// prevent, arriving with nothing said. Refused here instead, naming the key, which is the
+ /// same contract an unrepresentable type already gets.
+ static final int MAX_STRING_BYTES = 65535;
+
+ /// A map key trimmed to something a message can carry.
+ ///
+ /// The key itself may be the oversized thing being reported, and reproducing all of it in the
+ /// exception would bury the sentence that names the problem.
+ private static String keyLabel(String key) {
+ return key.length() <= 64 ? key
+ : key.substring(0, 64) + "...(" + key.length() + " chars)";
+ }
+
+ /// Refuses a string the local checkpoint could not store.
+ static void requireWritable(String value, String path) {
+ if (exceedsWritableLength(value)) {
+ throw new IllegalArgumentException("The continuity payload at \"" + path + "\" is "
+ + "longer than " + MAX_STRING_BYTES + " bytes of modified UTF-8, which is the "
+ + "most a stored checkpoint can hold. Keep the payload small -- it is a "
+ + "pointer to where the user was, not the document they were working on -- "
+ + "and load the rest from your own storage when the state is restored.");
+ }
+ }
+
+ /// Whether `s` encodes to more than MAX_STRING_BYTES.
+ ///
+ /// Counted rather than approximated from `length()`, because the limit is on BYTES and a
+ /// string of accented or CJK characters reaches it at a third of the character count. Stops
+ /// at the limit, so a huge string costs the limit rather than its own length, and the running
+ /// total cannot overflow.
+ static boolean exceedsWritableLength(String s) {
+ return writableLength(s) > MAX_STRING_BYTES;
+ }
+
+ /// The number of bytes `s` occupies in the modified UTF-8 a stored string is written as.
+ ///
+ /// Counted rather than approximated from `length()`, because the limit is on BYTES and a
+ /// string of accented or CJK characters reaches it at a third of the character count.
+ ///
+ /// Stops counting once past the limit, so a huge string costs the limit rather than its own
+ /// length and the running total cannot overflow. Callers may therefore read the answer as
+ /// "this many bytes, or more than the limit" -- which is all either of them needs, one to
+ /// refuse the string and the other to budget for it.
+ static int writableLength(String s) {
+ int len = 0;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (c >= 0x0001 && c <= 0x007F) {
+ len++;
+ } else if (c > 0x07FF) {
+ len += 3;
+ } else {
+ len += 2;
+ }
+ if (len > MAX_STRING_BYTES) {
+ return len;
+ }
+ }
+ return len;
+ }
+
+ 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 instanceof String) {
+ requireWritable((String) value, path);
+ return;
+ }
+ if (value instanceof Integer
+ || value instanceof Long || value instanceof Double || value instanceof Boolean) {
+ return;
+ }
+ if (value == null) {
+ // Refused rather than carried. A property list has no null: the iOS sanitizer drops a
+ // null-valued entry and drops a null LIST ELEMENT, which shifts every index after it,
+ // so the payload that arrives on the other device is a different shape from the one
+ // that was sent. Saying so here, where the key is known, beats a list that is quietly
+ // one shorter on an iPad.
+ throw new IllegalArgumentException("The continuity payload at \"" + path + "\" is "
+ + "null. A property list cannot carry one, and dropping it would change the "
+ + "shape of what arrives on another device -- a null list element would shift "
+ + "every index after it. Leave the key out instead.");
+ }
+ if (value instanceof List) {
+ List> list = (List>) value;
+ int index = 0;
+ for (Object element : list) {
+ check(element, path + "[" + index + "]", depth + 1);
+ index++;
+ }
+ return;
+ }
+ if (value instanceof Map) {
+ Map, ?> map = (Map, ?>) value;
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ 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.");
+ }
+ // Nested keys reach Util.writeObject's writeUTF exactly as top-level ones do.
+ // Validating only the top level left a deep key able to throw inside
+ // externalize(), which Continuity.persist() logs and carries on from -- so the
+ // checkpoint went out to the other device and was silently absent from local
+ // storage, the failure this validation exists to prevent.
+ requireWritable((String) key, path + "." + keyLabel((String) key));
+ check(entry.getValue(), path + "." + keyLabel((String) 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..036749287a7
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateProvider.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.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.
+ ///
+ /// #### Do not show a form from here
+ ///
+ /// Put the values where your screens will read them and return. `Continuity.restore()` answers
+ /// false for a payload-only state precisely so that the caller still shows its own screen:
+ ///
+ /// ```java
+ /// if (!Continuity.restore()) {
+ /// showDraftForm(); // reads what restoreState put in place
+ /// }
+ /// ```
+ ///
+ /// A review read the false as a defect -- the caller "shows its initial form over the one the
+ /// provider restored" -- which is only true of a provider that shows one. Returning true
+ /// instead would be the worse trade: an application whose provider only populates fields, the
+ /// shape recommended here, would then show nothing at all and come back to a blank screen.
+ /// False is the answer that is safe whichever the provider does.
+ ///
+ /// #### 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..771ede86f8f
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/StateRelay.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.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,
+ /// which the next checkpoint's publisher sends -- unless a newer state has superseded it by
+ /// then, or the user signed out in between. It is not retried on a timer: one attempt per
+ /// change beats spinning against an endpoint that is down.
+ 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..f09f92c7163
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java
@@ -0,0 +1,141 @@
+/*
+ * 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.
+ ///
+ /// Returns whether the store took it. A void signature made `SyncedStore.put` answer true
+ /// whenever a store merely existed, so the documented fallback -- write locally when the
+ /// synced write fails -- could never run, and a value the store refused was reported saved.
+ ///
+ /// #### Parameters
+ ///
+ /// - `key`: the key
+ /// - `value`: the value
+ ///
+ /// #### Returns
+ ///
+ /// true when the store holds the value afterwards
+ boolean 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. Ports must retain it and may call it from any
+ /// thread.
+ ///
+ /// It REPLACES the seam and may be called more than once, so a port that registers a native
+ /// observer here must register that observer once and only replace the reference. It is not
+ /// called once per listener -- the framework collapses those -- but it is called again at the
+ /// few moments its answer to a held continuation changes: enable(), disable(), clear(), and a
+ /// bridge the port has swapped.
+ ///
+ /// Re-installing is also how the framework asks for a continuation the port DECLINED earlier
+ /// and is holding. A port that offers a held activity when a callback is installed -- which
+ /// is what recovers a Handoff that cold-launched the app before anything was listening -- is
+ /// relying on exactly that, so a framework that installed strictly once would strand it.
+ ///
+ /// A held continuation offered in response to this call MUST be offered BEFORE this method
+ /// returns. Not a style note -- the framework's logout depends on it, and it is the one
+ /// ordering requirement this interface makes.
+ ///
+ /// `Continuity.clear()` empties the port as part of ending a session, and it does that by
+ /// installing a callback that discards whatever comes back. The window in which it discards
+ /// is this call, because the framework has no other way to draw the line: a held continuation
+ /// reaches `ContinuityCallback.continuationReceived` by exactly the same route a brand new one
+ /// does, carrying nothing that distinguishes them. A port that answered later would have its
+ /// pre-logout activity taken as a new arrival and restored into the account that just signed
+ /// in.
+ ///
+ /// Widening the window instead would break the other half of the same promise. `clear()` is a
+ /// logout, not "continuity off", and it deliberately leaves an enabled framework enabled, so a
+ /// continuation that genuinely arrives after it -- for the account now signing in -- has to be
+ /// delivered. Any window that outlasts the call starts eating those.
+ ///
+ /// Every port here already satisfies this: the iOS bridge hands its pending activity over
+ /// inline, and a bridge holding nothing satisfies it trivially. Writing it down is what stops
+ /// the next one from being the exception.
+ ///
+ /// #### 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..7230d4ff6d6
--- /dev/null
+++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java
@@ -0,0 +1,275 @@
+/*
+ * 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 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.
+/// #### Threading
+///
+/// Called on the event dispatch thread, like the rest of the toolkit. Codename One is single
+/// threaded by design -- one thread on each side of a native boundary, marshalled at the boundary
+/// rather than locked -- and this class follows that rule rather than making an exception to it.
+///
+/// It is worth stating because the simulation behind `isSupported() == true` on a desktop keeps
+/// its key index as a second stored value: two threads writing different NEW keys at once would
+/// each read that index, add their own key, and write it back, so one of them would vanish from
+/// `keys()` while its value stayed readable by name. The platform stores have no such structure
+/// and no such exposure. The answer is the toolkit's answer everywhere else -- call it from the
+/// event thread, and use `com.codename1.ui.Display#callSerially(Runnable)` if you are on another
+/// one -- not a lock inside a framework that does not have them.
+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 store holds the value afterwards; false when there is no store, or the
+ /// platform would not take it -- a key count or a size past what it allows
+ ///
+ /// #### Not gated on isSupported()
+ ///
+ /// That was the THIRD layer this was wrong in.
+ ///
+ /// isSupported() asks whether this build has a store that follows the user between devices,
+ /// which is the right question for an application deciding whether to offer the feature and
+ /// the wrong gate for the calls themselves. On iOS the store is a LOCAL persistent one whose
+ /// cloud propagation is asynchronous, so reads and writes work and reach other devices later.
+ ///
+ /// The gate was on all three of IOSNative.m, IOSContinuityBridge and here. Removing it from
+ /// the first two changed nothing, because this one still made every call unreachable -- a fix
+ /// verified at one layer and dead at the next. Each bridge answers for itself when there is
+ /// no store: the Android one returns null and no-ops, the iOS one checks its own port flag,
+ /// and the simulation reads local preferences.
+ 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 {
+ return b.syncedStorePut(key, value);
+ } 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+ // The callback the port delivers change notifications through, and NOT Continuity.enable():
+ // an app that only ever uses the synced store never touches Continuity itself, and would
+ // otherwise register a listener nothing could ever reach -- but enabling would also make
+ // every route change checkpoint, which on iOS advertises the app's navigation to the
+ // devices around it. A key/value store is not consent to broadcast a route stack.
+ Continuity.installSyncedStoreCallback();
+ // And this resolves the platform store, which is the half that actually creates it. On
+ // iOS the external-change observer is installed the first time the store is resolved, and
+ // enable() does not resolve it -- so an application that only registers a listener and
+ // waits to read values inside the callback was never told about a change made on another
+ // device, until some unrelated read or write happened to bring the store up. Idempotent:
+ // the port resolves it once and answers from that.
+ isSupported();
+ }
+
+ /// 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() {
+ // On the EDT: Continuity.Callback marshals the port's notification before it gets here.
+ // Copied before iterating, because 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);
+ // The element cast the compiler inserts sits in the loop header, outside the handler --
+ // a failed cast does not throw on the iOS virtual machine, so a handler wrapped around
+ // one could not run there anyway.
+ for (SyncedStoreListener l : snapshot) {
+ 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 536b93a39c2..7d9ff712556 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..9cbf765584f
--- /dev/null
+++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java
@@ -0,0 +1,571 @@
+/*
+ * 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.Storage;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+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.Storage`, so it survives a simulator
+/// restart the way the platform store survives a device one.
+///
+/// Storage rather than Preferences, and not as a detail. Preferences.set() fills an in-memory
+/// table whose save() discards the write's result, and Preferences.get() reads that table -- so a
+/// value that never reached the disk reads back correctly right up until the next launch, and a
+/// simulation that reported success for it would be teaching an application something false about
+/// the device.
+public class LocalContinuityBridge implements ContinuityBridge {
+ /// Prefix for the simulated synced store's keys inside `Storage`.
+ private static final String PREFIX = "CN1$SyncedStore$";
+
+ /// The list of keys, kept beside them because the store is addressed by name only.
+ ///
+ /// A SEPARATE namespace from the values, which is what makes it safe: PREFIX ends in `$` and
+ /// this does not, so no application key can ever be written to this name. Review read
+ /// `PREFIX + "Keys"` as landing here -- it produces `CN1$SyncedStore$Keys`, which is a value
+ /// like any other -- and the reasoning behind the answer is worth more than the answer: for a
+ /// collision to exist INDEX would have to start with PREFIX, and it does not.
+ ///
+ /// That is the property to preserve. Dropping the `$` from PREFIX, or renaming this to
+ /// something under it, would make `put("Keys", ...)` overwrite the index and then be
+ /// overwritten by it -- reported as a successful write whose value reads back as the key
+ /// list.
+ private static final String INDEX = "CN1$SyncedStoreKeys";
+
+ // EDT-owned. Everything here runs on the Codename One event thread: the framework calls in
+ // from there, and the simulator's "Simulate ->" items reach this class through
+ // SimulatorHookLoader, which dispatches every hook with Display.callSeriallyAndWait.
+ //
+ // The synced store below rests on the same assumption, and SyncedStore now says so where an
+ // application can read it: the key index is a second stored value, so two threads writing
+ // different new keys would each read it, add one key, and write it back over the other. That
+ // is answered by the toolkit's threading model rather than by a lock in it -- see the
+ // Threading section on SyncedStore.
+ private ContinuityCallback callback;
+ private String publishedType;
+ private String publishedTitle;
+ private Map publishedInfo;
+
+ @Override
+ public void setCallback(ContinuityCallback c) {
+ callback = c;
+ }
+
+ @Override
+ public boolean isContinuationSupported() {
+ return true;
+ }
+
+ @Override
+ public void publishContinuation(String activityType, String title,
+ Map userInfo) {
+ Map copy = userInfo == null ? null : deepCopy(userInfo);
+ publishedType = activityType;
+ publishedTitle = title;
+ publishedInfo = copy;
+ }
+
+ @Override
+ 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 : deepCopy(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 = deepCopy(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
+ // ------------------------------------------------------------------
+
+ @Override
+ public boolean isSyncedStoreSupported() {
+ return true;
+ }
+
+ @Override
+ public boolean syncedStorePut(String key, String value) {
+ // Storage, not Preferences, and the difference is the whole point of this method's
+ // return. Preferences.set() fills an in-memory table and its save() DISCARDS
+ // Storage.writeObject()'s result, so a write that never reached the disk leaves the new
+ // value in that table -- and Preferences.get() reads the table. The read-back below used
+ // to consult the cache it had just written and agree with itself, so put() reported
+ // success for a value that disappears when the simulator restarts. An oversized value
+ // makes it deterministic rather than a full-disk curiosity.
+ //
+ // Same correction the sequence counter and the delivery marks already needed. The
+ // simulation has to answer the question the device answers -- is the value there now --
+ // and only a checked write can.
+ if (!write(storageName(key), value)) {
+ return false;
+ }
+ List keys = indexKeys();
+ if (!keys.contains(key)) {
+ keys.add(key);
+ if (!writeIndex(keys)) {
+ // The value is stored and the index is not, so keys() would not list it. Rolled
+ // BACK rather than merely reported: leaving it made "false" a lie in the other
+ // direction -- the caller takes its documented fallback path while get() returns
+ // the value it was told had failed, keys() omits it, and clearing the store
+ // cannot reach it.
+ //
+ // A failed write should leave nothing behind, which is the only answer that means
+ // one thing.
+ //
+ // VERIFIED, for the same reason syncedStoreRemove() verifies: an unchecked delete
+ // made the rollback claim a cleanup it had not performed. When it cannot be
+ // performed there is nothing further this simulation can do -- the index write
+ // that would have listed the value is the one that just failed -- so it is
+ // logged rather than passed over, because the store is then in the one state
+ // this class works to avoid.
+ if (!deleteValue(storageName(key))) {
+ Log.p("Continuity synced store: the value for a key whose index write failed "
+ + "could not be deleted either, so it stays readable through get() "
+ + "while keys() does not list it: " + key);
+ }
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /// The storage name for an application key, encoded so that distinct keys cannot collide.
+ ///
+ /// Storage normalizes `/`, `\\`, `%`, `?`, `*`, `:` and `=` to `_` in a file name, so
+ /// "a/b" and "a_b" addressed the SAME value: both writes reported success, the index listed
+ /// both keys, and either read returned whichever was written last while removing one deleted
+ /// the other. That arrived with the move off Preferences -- which has no such rule -- so it
+ /// is a defect this class introduced while fixing a different one, not an old one.
+ ///
+ /// The storage name for one application key.
+ ///
+ /// A WHITELIST, not a list of characters to avoid. Everything outside `[a-z0-9_-]` becomes
+ /// `$` and four hex digits, so the name is pure ASCII made of characters no filesystem
+ /// rewrites -- and the simulation stops depending on which folding, normalisation or
+ /// reserved-name rules the host happens to apply.
+ ///
+ /// The list it replaces grew by one entry per review round and was wrong every time in a new
+ /// way: uppercase letters, because macOS and Windows fold case; a trailing dot or space,
+ /// because Windows trims them; `<`, `>`, `"` and `|`, because Windows refuses them; and then
+ /// canonically equivalent Unicode, because a decomposed and a precomposed accent can name one
+ /// file. Every one of those merged two distinct keys into a single value while the index went
+ /// on listing both -- both reads answering with the last write, and removing either removing
+ /// the other's value. A simulation that merges two keys is worse than no simulation: it looks
+ /// like it works. There is no reason to believe that list was finally complete, and with a
+ /// whitelist the question does not arise.
+ ///
+ /// The mapping is injective, which is what makes it collision-free: `$` is itself escaped, so
+ /// an escape group can never be produced by literal characters.
+ ///
+ /// PREFIX is left alone. It is a constant, identical in every name, so nothing about it can
+ /// distinguish one key from another -- and INDEX still cannot be reached from here, for the
+ /// reason its own comment gives.
+ private static String storageName(String key) {
+ StringBuilder sb = new StringBuilder(PREFIX);
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
+ sb.append(c);
+ continue;
+ }
+ sb.append('$');
+ String hex = Integer.toHexString(c).toUpperCase();
+ for (int pad = hex.length(); pad < 4; pad++) {
+ sb.append('0');
+ }
+ sb.append(hex);
+ }
+ if (sb.length() <= MAX_NAME_CHARS) {
+ return sb.toString();
+ }
+ // Escaping can multiply a key's length by five, and a filesystem will not take a name of
+ // any length. Truncated and then made unique again by a hash of the WHOLE key, so two
+ // long keys sharing a prefix still address different files.
+ return sb.toString().substring(0, MAX_NAME_CHARS) + "$$" + hash(key);
+ }
+
+ /// The most characters a storage name may use before it is truncated and hashed. Well inside
+ /// what every filesystem this simulation runs on accepts.
+ private static final int MAX_NAME_CHARS = 120;
+
+ /// FNV-1a, 64 bit, as 16 hex digits. Only ever used to keep two truncated names apart.
+ ///
+ /// Formatted through Integer.toHexString on each half rather than Long.toHexString, and
+ /// String.substring rather than StringBuilder.substring, because core is compiled a second
+ /// time against Ports/CLDC11 and translated against vm/JavaAPI -- neither of which defines
+ /// those two. The Maven build compiles against the full JDK and accepts them, so the mistake
+ /// only appears in the Ant leg.
+ private static String hash(String key) {
+ long h = 0xcbf29ce484222325L;
+ for (int i = 0; i < key.length(); i++) {
+ h ^= key.charAt(i);
+ h *= 0x100000001b3L;
+ }
+ return hex32((int) (h >>> 32)) + hex32((int) h);
+ }
+
+ /// One 32-bit half as exactly eight uppercase hex digits.
+ private static String hex32(int value) {
+ String hex = Integer.toHexString(value).toUpperCase();
+ StringBuilder out = new StringBuilder();
+ for (int pad = hex.length(); pad < 8; pad++) {
+ out.append('0');
+ }
+ out.append(hex);
+ return out.toString();
+ }
+
+ /// Writes one value, reporting whether it actually reached storage.
+ private boolean write(String name, String value) {
+ try {
+ return Storage.getInstance().writeObject(name, value);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Reads one value, or null for anything that is not a stored string.
+ private String read(String name) {
+ try {
+ if (!Storage.getInstance().exists(name)) {
+ return null;
+ }
+ Object o = Storage.getInstance().readObject(name);
+ // instanceof rather than a cast: a failed cast does not throw on the iOS virtual
+ // machine, and this class is compiled into every port.
+ return o instanceof String ? (String) o : null;
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ @Override
+ public String syncedStoreGet(String key) {
+ return read(storageName(key));
+ }
+
+ /// Deletes one stored value and reports whether it is DEFINITELY gone.
+ ///
+ /// The index and the values are two writes, and every caller here has to know which of them
+ /// happened. Dropping the index entry for a value the delete failed to remove leaves the old
+ /// value readable through get() while keys() omits it and clearing the store cannot reach it
+ /// -- a value with no way to see it and no way to remove it.
+ ///
+ /// When the check itself fails the answer is "still there", which is the safe direction: an
+ /// index entry for a value that has gone shows up as a key whose get() answers the default,
+ /// and an application can see that and cope. The other way round is invisible.
+ private boolean deleteValue(String name) {
+ try {
+ Storage.getInstance().deleteStorageFile(name);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ try {
+ return !Storage.getInstance().exists(name);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ @Override
+ public void syncedStoreRemove(String key) {
+ if (!deleteValue(storageName(key))) {
+ // The value is still readable, so the index keeps its entry. Reporting a key whose
+ // value is still there is the truth; dropping it would hide a value that get() goes
+ // on returning.
+ return;
+ }
+ List keys = indexKeys();
+ if (keys.remove(key) && !writeIndex(keys)) {
+ // The value is gone and the index still lists it. syncedStoreKeys() filters that out
+ // so keys() stays truthful for the rest of the process, and this says so once rather
+ // than leaving a durable index that disagrees with the store entirely unremarked.
+ Log.p("Continuity synced store: the value for \"" + key + "\" was removed but the "
+ + "index could not be rewritten, so the stored index still lists it until a "
+ + "later write succeeds.");
+ }
+ }
+
+ @Override
+ public String[] syncedStoreKeys() {
+ // FILTERED by what is actually stored, because the index is a second write and can be
+ // left describing a value that is not there: a delete that succeeded and an index write
+ // that then failed leaves the key listed while get() answers the default. The platform
+ // this simulates has no such gap -- NSUbiquitousKeyValueStore enumerates its own
+ // dictionary, so a phantom key cannot exist there -- and the simulation should not
+ // invent one.
+ //
+ // Only a POSITIVE absence removes a key. When the check cannot be made the entry stays,
+ // which is the same direction deleteValue() chose and for the same reason: a listed key
+ // whose get() answers the default is visible and survivable, and a value nothing lists
+ // is neither.
+ List keys = indexKeys();
+ List present = new ArrayList();
+ for (String key : keys) {
+ if (definitelyAbsent(storageName(key))) {
+ continue;
+ }
+ present.add(key);
+ }
+ return present.toArray(new String[present.size()]);
+ }
+
+ /// Whether this storage name is known NOT to hold a value. False when it does, and false
+ /// when the question could not be answered.
+ private boolean definitelyAbsent(String name) {
+ try {
+ return !Storage.getInstance().exists(name);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// 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 = read(INDEX);
+ if (raw == null || raw.length() == 0) {
+ return keys;
+ }
+ // Newline separated AND escaped. The separator alone was not enough: a key containing a
+ // newline is one this API accepts -- the platform store imposes no such rule, so neither
+ // does the simulation -- and it came back from here as two phantom keys that nothing
+ // could then remove.
+ int start = 0;
+ while (start <= raw.length()) {
+ int end = raw.indexOf('\n', start);
+ if (end < 0) {
+ end = raw.length();
+ }
+ String key = unescapeIndexEntry(raw.substring(start, end));
+ if (key.length() > 0 && !keys.contains(key)) {
+ keys.add(key);
+ }
+ start = end + 1;
+ }
+ return keys;
+ }
+
+ /// Escapes a key for the newline-separated index: backslash first, then the separator.
+ private static String escapeIndexEntry(String key) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < key.length(); i++) {
+ char c = key.charAt(i);
+ if (c == '\\') {
+ sb.append("\\\\");
+ } else if (c == '\n') {
+ sb.append("\\n");
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ /// Reverses `escapeIndexEntry`.
+ private static String unescapeIndexEntry(String entry) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < entry.length(); i++) {
+ char c = entry.charAt(i);
+ if (c == '\\' && i + 1 < entry.length()) {
+ char next = entry.charAt(i + 1);
+ if (next == 'n') {
+ sb.append('\n');
+ i++;
+ continue;
+ }
+ if (next == '\\') {
+ sb.append('\\');
+ i++;
+ continue;
+ }
+ }
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
+ /// Writes the key index, reporting whether it reached storage.
+ ///
+ /// The answer is used rather than logged: a value stored under a key the index has lost is
+ /// findable by name and invisible to keys(), and a caller told its write succeeded has been
+ /// told something that is only half true.
+ private boolean writeIndex(List keys) {
+ StringBuilder sb = new StringBuilder();
+ for (String key : keys) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(escapeIndexEntry(key));
+ }
+ return write(INDEX, sb.toString());
+ }
+
+ /// A real copy of a payload, nested lists and maps included.
+ ///
+ /// AppState already made this decision for its own snapshot -- "a shallow copy left the
+ /// snapshot sharing the application's own lists and maps ... a snapshot has to be a snapshot"
+ /// -- and this class contradicted it at the three places it hands a payload across. What is
+ /// advertised here is what the simulator shows and what tests assert on, so a caller that
+ /// reaches into a nested list was editing the record of what had been published: the next
+ /// simulateArrival() then delivered the edit rather than the checkpoint.
+ ///
+ /// A test double that shares state it says it copied is worse than one that is simply wrong,
+ /// because what it breaks is the test's ability to notice.
+ ///
+ /// Its own copy rather than AppState's, which is private and belongs to a different package.
+ /// Fifteen lines here is a smaller price than widening that class's surface for an impl.
+ private static Map deepCopy(Map p) {
+ Map out = new HashMap();
+ for (Map.Entry e : p.entrySet()) {
+ out.put(e.getKey(), copyValue(e.getValue()));
+ }
+ return out;
+ }
+
+ private static Object copyValue(Object value) {
+ if (value instanceof List) {
+ List> in = (List>) value;
+ List out = new ArrayList(in.size());
+ for (Object element : in) {
+ out.add(copyValue(element));
+ }
+ return out;
+ }
+ if (value instanceof Map) {
+ Map, ?> in = (Map, ?>) value;
+ Map out = new HashMap();
+ for (Map.Entry, ?> e : in.entrySet()) {
+ if (e.getKey() instanceof String) {
+ out.put((String) e.getKey(), copyValue(e.getValue()));
+ }
+ }
+ return out;
+ }
+ // Everything else a payload may carry is immutable -- String, the boxed primitives -- so
+ // sharing it is sharing a value, not a container.
+ return value;
+ }
+
+}
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/io/rest/RequestBuilder.java b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java
index 9ef7c721c69..1e3f079f73a 100644
--- a/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java
+++ b/CodenameOne/src/com/codename1/io/rest/RequestBuilder.java
@@ -79,6 +79,12 @@ public class RequestBuilder {
private ErrorCodeHandler byteArrayErrorCallback;
private ErrorCodeHandler jsonErrorCallback;
private ErrorCodeHandler stringErrorCallback;
+
+ /// Whether a redirect may be followed, or NULL when the caller has not said.
+ ///
+ /// Three states rather than two: unspecified has to leave ConnectionRequest's global default
+ /// alone, and only an explicit call may override it -- in either direction.
+ private Boolean followRedirects;
private ErrorCodeHandler propertyErrorCallback;
private Class errorHandlerPropertyType;
//private ActionListener errorCallback;
@@ -420,6 +426,30 @@ public RequestBuilder onErrorCode(ErrorCodeHandler err,
/// #### Returns
///
/// RequestBuilder instance
+ /// Whether this request may follow a redirect. Requests follow them by default.
+ ///
+ /// Turn it off for a request that carries CREDENTIALS. A redirect is followed with the same
+ /// headers, so a 307 hands the Authorization header -- and the body -- to whatever host the
+ /// response names, including an `http://` one, which silently undoes a caller that was
+ /// careful to use HTTPS. A 302 or 303 is not safer, only different: it turns a POST into a
+ /// GET and the final 2xx then reports success for a write that never happened.
+ ///
+ /// `ConnectionRequest` has always had this per request; this passes it through, which is all
+ /// that was missing.
+ ///
+ /// #### Parameters
+ ///
+ /// - `follow`: false to refuse redirects
+ ///
+ /// #### Returns
+ ///
+ /// RequestBuilder instance
+ public RequestBuilder followRedirects(boolean follow) {
+ checkFetched();
+ followRedirects = Boolean.valueOf(follow);
+ return this;
+ }
+
public RequestBuilder onErrorCodeString(ErrorCodeHandler err) {
checkFetched();
stringErrorCallback = err;
@@ -1044,6 +1074,14 @@ private Connection createRequest(boolean parseJson) {
req.setContentType(contentType);
}
req.setFailSilently(hasErrorCodeHandler());
+ if (followRedirects != null) {
+ // Only when the CALLER said so. Applying the field unconditionally would push this
+ // builder's default over ConnectionRequest.setDefaultFollowRedirects for every
+ // request that never asked, and testing it as a plain boolean made an explicit
+ // followRedirects(true) unreachable whenever the global default was false -- which is
+ // the opposite of a per-request setting.
+ req.setFollowRedirects(followRedirects.booleanValue());
+ }
if (cache != null) {
req.setCacheMode(cache);
}
diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java
index 0a582da09ef..af9fc3f0270 100644
--- a/CodenameOne/src/com/codename1/router/Navigation.java
+++ b/CodenameOne/src/com/codename1/router/Navigation.java
@@ -103,8 +103,27 @@ public static boolean navigate(String path) {
if (f == null) {
return false;
}
+ List before = new ArrayList(stack);
stack.add(new NavigationEntry(path, f));
- f.show();
+ // BEFORE show(), which runs application code. The stack has already changed here, and
+ // that is what continuity records -- so notifying now means a show callback that ends the
+ // session (an expired login discovered on screen) is answered by clear() clearing the
+ // pending flag, and the flush queued a moment ago then finds nothing owed. Notifying
+ // afterwards described a session the callback had already ended, and checkpointed the
+ // signed-out account's payload.
+ stackChanged();
+ List expected = new ArrayList(stack);
+ try {
+ f.show();
+ } catch (RuntimeException e) {
+ // The entry goes back. show() can throw before the form is ever installed, and the
+ // entry left behind was a screen the user never saw -- persisted by the checkpoint
+ // this method has already queued, and restored after a process death. The flush reads
+ // the stack when it runs, so putting it back is what makes that checkpoint describe
+ // the truth.
+ rollBack(before, expected, f);
+ throw e;
+ }
return true;
}
@@ -116,9 +135,19 @@ public static boolean back() {
if (stack.size() <= 1) {
return false;
}
+ List before = new ArrayList(stack);
stack.remove(stack.size() - 1);
NavigationEntry now = stack.get(stack.size() - 1);
- now.getForm().showBack();
+ Form back = now.getForm();
+ // Before showBack(), for the reason navigate() gives.
+ stackChanged();
+ List expected = new ArrayList(stack);
+ try {
+ back.showBack();
+ } catch (RuntimeException e) {
+ rollBack(before, expected, back);
+ throw e;
+ }
return true;
}
@@ -134,6 +163,71 @@ public static List getStack() {
return Collections.unmodifiableList(new ArrayList(stack));
}
+ /// Undoes a navigation whose show() threw -- but only if nothing else touched the stack
+ /// while it ran.
+ ///
+ /// `show()` runs application code, and that code can navigate. The case that matters is
+ /// `Continuity.clear()`: a show listener discovers the session has expired, logs out --
+ /// which empties this stack on purpose -- and then throws on the way out. Restoring
+ /// unconditionally handed the signed-out account's forms straight back, reachable through
+ /// `getStack()` and `back()` and persisted by the next checkpoint. That is the one thing the
+ /// logout existed to prevent, undone by the rollback meant to help it.
+ ///
+ /// So the rollback applies only when the stack is still exactly what this method left it as.
+ /// Anything else -- a logout, or a show listener that navigated somewhere of its own -- is a
+ /// deliberate change by code that ran later, and it wins. `NavigationEntry` does not override
+ /// equals, so comparing the lists compares entry IDENTITY, which is what makes "still exactly
+ /// what I left" mean what it says.
+ private static void rollBack(List before, List expected,
+ Form intended) {
+ if (!expected.equals(stack)) {
+ return;
+ }
+ if (Display.getInstance().getCurrent() == intended) { //NOPMD CompareObjectsWithEquals
+ // The form IS on screen. show() installs the form and only then runs onShowCompleted
+ // and the show listeners, so a throw from one of those is a failure that happened
+ // AFTER the navigation succeeded -- the entry describes the screen the user is
+ // looking at, and rolling it back would leave Navigation.getCurrent() disagreeing
+ // with Display.getCurrent(): back() would work on a stack whose top is not the
+ // visible form, and a checkpoint would persist a screen the user is not on.
+ //
+ // The rollback is for the other case, which is the one it was written for: show()
+ // threw BEFORE installing anything, so the entry is a screen nobody ever saw.
+ //
+ // Re-showing the previous form instead was the alternative, and it is worse: it runs
+ // a second full show cycle -- transitions, listeners, whatever they do -- as error
+ // handling, on a form the application has not asked to see again, and that cycle can
+ // throw in its turn. Leaving the stack agreeing with the display costs nothing and
+ // needs no application code to run.
+ return;
+ }
+ stack.clear();
+ stack.addAll(before);
+ }
+
+ /// Forgets the navigation history, leaving nothing to go back to.
+ ///
+ /// For a logout, which is the case that needs it: `Continuity.clear()` calls this, because a
+ /// route stack is the previous account's work as surely as a stored checkpoint is. Left in
+ /// place it kept two promises broken -- `Navigation#back()` reopened the signed-out account's
+ /// forms, and the next navigation checkpointed and republished a stack that still began with
+ /// their routes.
+ ///
+ /// The forms themselves are not touched: whatever is on screen stays there, and the caller
+ /// navigates wherever it means to go next.
+ ///
+ /// Continuity IS notified, because for every caller except a logout this is a real change to
+ /// where the user has been. It used to stay silent so that `Continuity.clear()` could call it
+ /// without checkpointing the emptied stack back over what it was deleting -- but that made
+ /// every other caller silent too: an application forgetting its back history and then not
+ /// navigating left the previous routes in the stored checkpoint, so a process death restored
+ /// exactly what it had just cleared. The logout path suppresses this at its own end, where
+ /// the reason to suppress it lives.
+ public static void clearStack() {
+ stack.clear();
+ stackChanged();
+ }
+
/// Pop entries until `entry` is on top, then show its form via
/// `Form#showBack`. Returns `true` when the entry was on the stack and
/// we navigated back to it, `false` when the entry is not on the stack.
@@ -158,13 +252,213 @@ public static boolean popTo(NavigationEntry entry) {
if (idx == stack.size() - 1) {
return true;
}
+ List before = new ArrayList(stack);
while (stack.size() > idx + 1) {
stack.remove(stack.size() - 1);
}
- entry.getForm().showBack();
+ Form target = entry.getForm();
+ // Before showBack(), for the reason navigate() gives.
+ stackChanged();
+ List expected = new ArrayList(stack);
+ try {
+ target.showBack();
+ } catch (RuntimeException e) {
+ rollBack(before, expected, target);
+ throw e;
+ }
+ 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
+ /// A route factory that THROWS propagates, rather than being skipped: it is a failure the
+ /// caller can retry, not a route this build has stopped registering. A factory that answers
+ /// null is still skipped.
+ public static boolean restoreStack(List paths) {
+ RouteDispatcher d = dispatcher;
+ if (d == null || paths == null || paths.isEmpty()) {
+ return false;
+ }
+ // The stack as it stands BEFORE any factory runs, so a factory that navigates can be
+ // told apart from one that does not. See the check after the loop.
+ List beforeDispatch = new ArrayList(stack);
+ List rebuilt = new ArrayList();
+ for (String path : paths) {
+ if (!beforeDispatch.equals(stack)) {
+ // A FACTORY navigated, and the rebuild stops here rather than at the end. Every
+ // later factory would still construct its screen and read or write whatever the
+ // application keeps behind it -- an unavailable parent redirecting to a safe list
+ // while its child factories go on touching the record that is unavailable -- and
+ // all of it would then be discarded in favour of the redirect. Asked per
+ // iteration for the same reason the session check beside it is.
+ return false;
+ }
+ if (sessionEnded()) {
+ // A factory ended the continuity session -- it found the account signed out,
+ // which is exactly the decision a route factory is entitled to make. Every later
+ // factory would run for that account: constructing forms, and whatever they query
+ // or write on the way. The lifecycle check in Continuity.restore() happens after
+ // this method returns and can only empty the stack afterwards, which undoes none
+ // of it.
+ return false;
+ }
+ if (path == null || path.length() == 0) {
+ continue;
+ }
+ // NOT caught. A factory that THROWS is a different thing from a route this build no
+ // longer registers, and swallowing it made them the same: the failed screen was
+ // skipped, an earlier one was shown, and this method returned success -- so
+ // Continuity.restore() persisted and acknowledged a partial state, the relay stopped
+ // offering it, and the user was left on the wrong screen with no copy to retry from.
+ //
+ // An unregistered route answers null and is still skipped, which is the tolerance
+ // that was wanted: it will not start working on the next launch either. A throw is
+ // the transient breakage -- a dependency not up yet on a cold launch -- and letting
+ // it out is what makes the restore retryable. The method already propagates a throw
+ // from show() for the same reason.
+ Form f = d.dispatch(path);
+ if (f != null) {
+ rebuilt.add(new NavigationEntry(path, f));
+ }
+ }
+ if (!beforeDispatch.equals(stack)) {
+ // Asked again, because the loop tests before each factory and the LAST one has no
+ // next iteration to be stopped by -- the same pairing the session check uses.
+ //
+ // A route factory is application code and may redirect: an expired detail page
+ // sending the user to a list, a screen that has moved. It does so before this method
+ // has installed anything, so the rebuild would replace both its stack entry and its
+ // screen. Its choice wins, which is the rule the rollback below and the ordinary
+ // navigations already use.
+ //
+ // Continuity notices the stack moved and settles the arrival rather than treating it
+ // as one that failed -- see the note there. It also checkpoints what the factory left
+ // behind, so the redirect survives a process death.
+ return false;
+ }
+ if (rebuilt.isEmpty() || sessionEnded()) {
+ // Asked again, because the loop tests before each factory and the LAST one has no
+ // "next" iteration to be stopped by. Showing here would put the signed-out account's
+ // screen in front of the user.
+ return false;
+ }
+ // The PREVIOUS stack is kept until the new one is really on screen. show() runs
+ // application code -- the form's own show handling, and whatever listens to it -- and if
+ // that throws, the old form is still displayed while getCurrent(), back() and the next
+ // checkpoint would all be describing a stack the user never saw. A later navigation then
+ // persists a restoration that failed.
+ List previous = new ArrayList(stack);
+ stack.clear();
+ stack.addAll(rebuilt);
+ // Resolved BEFORE the try, and that is not tidying. Reading from a generic list compiles
+ // to a checkcast, and this virtual machine's CHECKCAST expands to nothing -- so a failed
+ // cast hands the wrong object to the next instruction instead of throwing, and a handler
+ // that catches RuntimeException around it is a handler that can never run. The guarded
+ // region has to contain only the call being guarded. check-cast-semantics.sh refuses the
+ // other shape, correctly, and caught this exact line.
+ Form top = rebuilt.get(rebuilt.size() - 1).getForm();
+ // What is DISPLAYED before the attempt, which is not the same thing as the stack.
+ // Display.setCurrentForm() installs the new form and only then runs onShowCompleted and
+ // the Show listeners, so a listener that throws leaves the failed form current -- and
+ // restoring the list alone left getCurrent() and Navigation.getCurrent() describing
+ // different screens.
+ Form displayed = Display.getInstance().getCurrent();
+ try {
+ // 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.
+ top.show();
+ } catch (RuntimeException e) {
+ // The STACK, and only while it is still exactly what this method installed. The
+ // display test below was added first and left this one unconditional, which made the
+ // two disagree: a show callback that navigates somewhere of its own and then throws
+ // -- or that navigates and a later listener throws -- has already changed both, so
+ // erasing the stack while leaving its screen up describes a place the user is not.
+ //
+ // Same rule as the ordinary navigations use, for the same reason: whatever ran later
+ // and changed the stack meant to, and it wins.
+ if (rebuilt.equals(stack)) {
+ stack.clear();
+ stack.addAll(previous);
+ }
+ // And the screen with it -- but ONLY when the screen still showing is the one this
+ // method put up. show() rather than showBack(): the user is not going back, an
+ // attempt that failed is being undone.
+ //
+ // The test used to be "the display changed", which is true of two different things.
+ // show() installs the form and only THEN runs onShowCompleted and the show listeners,
+ // so a listener that finds the session expired, calls Continuity.clear(), opens a
+ // login form and then throws has already replaced the screen with its own choice --
+ // and re-showing the pre-restore form put the signed-out account's screen back in
+ // front of the user, which is the one thing that callback ran to prevent.
+ //
+ // Asking whether `top` is still current separates them: if it is, nothing has been
+ // chosen since and the restore's own screen comes down; if it is not, application
+ // code put something else there and it stays. Continuity.restore() cannot make this
+ // distinction on its own -- by the time it runs, this rollback has already happened.
+ try {
+ Form now = Display.getInstance().getCurrent();
+ if (displayed != null && now == top) { //NOPMD CompareObjectsWithEquals
+ displayed.show();
+ }
+ } catch (RuntimeException ignored) {
+ // The original failure is the one worth reporting; losing it to a second one
+ // while cleaning up would hide what actually went wrong.
+ com.codename1.io.Log.e(ignored);
+ }
+ throw e;
+ }
+ stackChanged();
return true;
}
+ /// Whether a continuity restore in progress has had its session ended underneath it.
+ ///
+ /// Answers false for every application that does not use continuity, and for every navigation
+ /// that is not a restore, which is why it can sit in this loop.
+ private static boolean sessionEnded() {
+ try {
+ return com.codename1.continuity.Continuity.restoreSessionEnded();
+ } catch (Throwable t) {
+ // Carrying on is the answer that keeps ordinary navigation working; this guard exists
+ // for the signed-out case, not to gate the routing API on the continuity framework
+ // being answerable.
+ com.codename1.io.Log.e(t);
+ return false;
+ }
+ }
+
+ /// 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 3ab45d454ae..9d27b48af83 100644
--- a/CodenameOne/src/com/codename1/ui/Display.java
+++ b/CodenameOne/src/com/codename1/ui/Display.java
@@ -5888,6 +5888,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 599c53cc155..4bbb38b640b 100644
--- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
+++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java
@@ -6504,6 +6504,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..afeb22047e7
--- /dev/null
+++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java
@@ -0,0 +1,222 @@
+/*
+ * 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 com.codename1.ui.Display;
+
+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 {
+
+ /// How long the suspend flush may hold Android's main thread waiting for the event thread.
+ ///
+ /// Bounded because the alternative is an ANR: if the event thread is wedged, waiting forever
+ /// turns a missed checkpoint into a killed application. The state written by the last
+ /// navigation is still on disk when this gives up.
+ private static final int CHECKPOINT_TIMEOUT_MILLIS = 1500;
+
+ /// 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);
+ }
+ }
+
+ @Override
+ 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.
+ }
+
+ @Override
+ public boolean isContinuationSupported() {
+ return false;
+ }
+
+ @Override
+ public void publishContinuation(String activityType, String title,
+ Map userInfo) {
+ }
+
+ @Override
+ public void clearContinuation() {
+ }
+
+ @Override
+ public boolean isSyncedStoreSupported() {
+ return false;
+ }
+
+ @Override
+ public boolean syncedStorePut(String key, String value) {
+ return false;
+ }
+
+ @Override
+ public String syncedStoreGet(String key) {
+ return null;
+ }
+
+ @Override
+ public void syncedStoreRemove(String key) {
+ }
+
+ @Override
+ public String[] syncedStoreKeys() {
+ return new String[0];
+ }
+
+ /// The checkpoint, as a constant rather than an anonymous class per callback.
+ ///
+ /// It captures nothing -- everything it touches is static -- so an inner class would hold the
+ /// listener alive for no reason and allocate on a path that runs at every suspend.
+ private static final Runnable CHECKPOINT = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ if (!Continuity.isCheckpointPending()) {
+ // Asked HERE rather than before the hop. The framework writes through as the
+ // user navigates, so by the time Android says it may kill the process there
+ // is usually nothing owed -- but the answer lives in EDT-owned fields, and
+ // reading it from Android's main thread was the one place this port reached
+ // into the framework's state from off the event thread.
+ return;
+ }
+ Continuity.checkpoint();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ };
+
+ /// The resume poll, as a constant for the reason CHECKPOINT is one.
+ private static final Runnable POLL = new Runnable() {
+ @Override
+ public void run() {
+ try {
+ Continuity.pollRelay();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ };
+
+ /// 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.
+ //
+ // Marshalled, and NOT waited for. This runs on Android's main thread and pollRelay()
+ // reads EDT-owned fields to decide whether a fetch is already out; blocking here for
+ // a request that returns immediately anyway would only slow every resume.
+ try {
+ Display.getInstance().callSerially(POLL);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ @Override
+ public void onPause() {
+ }
+
+ @Override
+ public void onDestroy() {
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle b) {
+ try {
+ // Onto the Codename One event thread, and waited for. This callback runs on
+ // Android's own main thread, which is not the EDT: StateProvider.saveState is
+ // application code documented to run on the EDT, and the route stack it is
+ // captured beside is an EDT-owned list. Reading either from here would race the
+ // running application, so the whole decision -- including whether anything is
+ // owed at all -- is made on the other side of the hop.
+ //
+ // Waiting blocks Android's main thread. That is the cost of a guaranteed flush at
+ // the last callback before the process can be reclaimed, and it is bounded.
+ Display.getInstance().callSeriallyAndWait(CHECKPOINT, CHECKPOINT_TIMEOUT_MILLIS);
+ } 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..18e79806a2c
--- /dev/null
+++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.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.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() {
+ }
+
+ /*
+ * Each canned arrival above has its OWN origin id, and none of them is "simulated-device".
+ *
+ * That is the id LocalContinuityBridge.simulateArrival() stamps on the app's real checkpoint
+ * for "continue on this device", and these states carry a sequence of
+ * System.currentTimeMillis() -- around 1.7e12, against the small counter a real checkpoint
+ * uses. Sharing the id meant one click of any item here recorded a high-water mark that the
+ * genuine "Continue Here" could never beat, so it was silently refused as stale for the rest
+ * of the process, and for good once a canned state was durably acknowledged. The menu item
+ * that demonstrates the feature broke the menu item that demonstrates the feature.
+ *
+ * Separate origins rather than a shared counter: these are meant to look like different
+ * devices anyway, and nothing here needs them ordered against each other.
+ */
+ 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-older-build")
+ .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-payload-only")
+ .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-yesterday")
+ .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;
+ }
+
+ // The OPERATIONS as well, not only the answer about them. The framework deliberately
+ // stopped gating store calls on isSyncedStoreSupported() -- on iOS the store is local
+ // and works whether or not this build is entitled to sync it -- so overriding the
+ // predicate alone left this simulation with a fully working store, and an application
+ // that ignores isSupported() kept its setting here while losing it on Android. That
+ // is the exact failure the menu item exists to reproduce.
+ //
+ // These are Android's answers, which is the platform being simulated: a write that
+ // does not happen, a read that finds nothing, and a removal with nothing to remove.
+ @Override
+ public boolean syncedStorePut(String key, String value) {
+ return false;
+ }
+
+ @Override
+ public String syncedStoreGet(String key) {
+ return null;
+ }
+
+ @Override
+ public void syncedStoreRemove(String key) {
+ }
+
+ @Override
+ public String[] syncedStoreKeys() {
+ return new String[0];
+ }
+ });
+ }
+
+ /// 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 79450678093..20e1051d4b2 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..53238a95f40 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,73 @@ - (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.
+ //
+ // Matched EXACTLY, against the type the BUILD resolved. A suffix test claimed any activity
+ // whose type merely ended in ".continuity" -- a donated App Intent with such an id, say --
+ // and on a cold launch that arrival was parked and reported handled before anything could
+ // tell it apart, so it never reached the intents dispatcher below and nothing rerouted it
+ // once initialization revealed the mismatch.
+ //
+ // Read from the plist rather than derived from [[NSBundle mainBundle] bundleIdentifier].
+ // Deriving it looks equivalent and is wrong on the Mac slice:
+ // DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the Catalyst bundle id
+ // ".maccatalyst", so the derived type would be ".maccatalyst.continuity"
+ // while the type declared in NSUserActivityTypes and published by every device is
+ // ".continuity" -- Handoff silently dead on Catalyst, which is the Mac-to-iPhone
+ // case this feature is for. IPhoneBuilder writes CN1ContinuityActivityType beside that
+ // declaration from the one value it resolved, and the Mac plist is generated from the
+ // finished iOS one, so both slices read the same string.
+ //
+ // Absent means no builder that knows this feature generated the project, so the activity is
+ // left to the branch below rather than guessed at.
+ NSString *cn1ContinuityExpected = [[NSBundle mainBundle]
+ objectForInfoDictionaryKey:@"CN1ContinuityActivityType"];
+ if (![cn1ContinuityExpected isKindOfClass:[NSString class]]
+ || [cn1ContinuityExpected length] == 0) {
+ cn1ContinuityExpected = nil;
+ }
+ if (userActivity != nil && cn1ContinuityExpected != nil
+ && [userActivity.activityType isEqualToString:cn1ContinuityExpected]) {
+ 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 +597,30 @@ - (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.
+ //
+ // This dictionary is the LEGACY lifecycle's cold-launch path only. On the default
+ // UIScene build the activity arrives through UISceneConnectionOptions instead,
+ // and CodenameOne_GLSceneDelegate's willConnectToSession already forwards
+ // connectionOptions.userActivities to cn1ContinueUserActivity: at the end of the
+ // same method that installs the root view controller. A review read that method
+ // as not forwarding them and asked for this block to cover the scene path; it
+ // does not need to. The scene path's own ordering problem -- willConnectToSession
+ // runs before init() -- is solved on the Java side, where
+ // IOSContinuityCallbacks holds an activity that arrives before setCallback and
+ // delivers it when Continuity.enable() installs one.
if (![NSUserActivityTypeBrowsingWeb isEqualToString:userActivity.activityType]) {
cn1PendingLaunchActivity = [userActivity retain];
} else {
@@ -685,10 +776,13 @@ - (UISceneConfiguration *)application:(UIApplication *)application configuration
}
#endif
-// Compiled for universal links OR intents: without the second condition a Spotlight tap on a
-// legacy-lifecycle build (ios.uiscene=false) would silently do nothing, since the scene delegate
-// is what routes this on a default build.
-#if defined(CN1_HANDLE_UNIVERSAL_LINKS) || defined(CN1_USE_INTENTS)
+// Compiled for universal links OR intents OR continuity: without the second and third conditions
+// a Spotlight tap, or a handoff from the user's other device, on a legacy-lifecycle build
+// (ios.uiscene=false) would silently do nothing, since the scene delegate is what routes this on
+// a default build. Continuity was added here for exactly the reason intents was: the branch it
+// needs inside cn1ContinueUserActivity: is compiled, and on a legacy build nothing ever calls it.
+#if defined(CN1_HANDLE_UNIVERSAL_LINKS) || defined(CN1_USE_INTENTS) \
+ || defined(CN1_USE_CONTINUITY)
// https://developer.apple.com/documentation/uikit/core_app/allowing_apps_and_websites_to_link_to_your_content?language=objc
// https://github.com/codenameone/CodenameOne/issues/2677
- (BOOL)application:(UIApplication *)application
diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h
index e8d27687bea..fef2be32d7c 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,26 @@ 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, as a SCOPE decision rather than an availability one, and the
+// difference matters because the comment here used to claim the wrong thing. Both APIs exist on
+// these platforms: Foundation declares NSUserActivity as watchos(2.0)/tvos(9.0) and
+// NSUbiquitousKeyValueStore as watchos(9.0)/tvos(9.0). What this feature ships and tests is the
+// phone-to-phone and phone-to-Mac case, so the natives are left out of the watch and TV slices
+// rather than shipped untested.
+//
+// Nothing misreports itself as a result. isContinuationSupported() and isSyncedStoreSupported()
+// both answer false on those slices, which is true of the build even though it is not true of
+// the platform, and an application branches on those rather than on which device it is. The Java
+// half is unaffected either way -- a watch app still saves and restores its own state, which is
+// the half that needs no native support.
+//
+// Turning either on later means giving the synced store its own define rather than widening this
+// one, since the two capabilities are advertised independently and only one of them has anything
+// to do with Handoff.
#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 18a1185e371..665185205c1 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -20545,6 +20545,462 @@ 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 process has no store object at all.
+///
+/// NOT gated on the synchronize probe, which answers a different question. A
+/// NSUbiquitousKeyValueStore is a LOCAL persistent store: reads and writes go to disk and iCloud
+/// propagation happens asynchronously afterwards, and -synchronize pushes the in-memory copy to
+/// that disk rather than to the network. Refusing to hand the store out because a synchronize
+/// answered NO therefore threw away work that would have persisted and propagated perfectly well
+/// -- get() ignored values already cached locally, and put() and remove() did nothing at all --
+/// for as long as the NO lasted.
+///
+/// Whether the store is USABLE and whether this build is ENTITLED are separate questions, and the
+/// probe belongs to the second one. See cn1ContinuitySyncEntitled.
+/// Whether a synchronize has ever succeeded, which is how a missing entitlement shows itself.
+///
+/// File scope rather than a function static so isSupported() can read it: what the application is
+/// told about support has to be this, and not "is there a store object", which is now yes for an
+/// unentitled build too.
+static BOOL cn1ContinuitySyncEntitled = NO;
+
+static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) {
+ static NSUbiquitousKeyValueStore *store = nil;
+ static pthread_mutex_t cn1ContinuityStoreLock = PTHREAD_MUTEX_INITIALIZER;
+ // A mutex that latches SUCCESS only, not dispatch_once. Two things have to be true here and
+ // they pull in opposite directions.
+ //
+ // It must be serialized: an earlier version set a "resolved" flag BEFORE assigning the store,
+ // so a second thread arriving in that gap got nil back from a store that was perfectly
+ // available, and two threads passing together installed the external-change observer twice --
+ // every remote change delivered to the listener twice.
+ //
+ // But it must NOT latch failure. [s synchronize] is the probe for whether this build is
+ // ENTITLED -- Apple gives a missing entitlement as the example of what makes it answer NO --
+ // and a one-time initializer cached that NO for the life of the process. An app whose first
+ // probe failed for any other reason then reported the synced store unsupported forever, with
+ // no observer, however long the process ran. Probing again on a later call costs one
+ // synchronize; getting it permanently wrong costs the feature.
+ //
+ // The probe is deliberately not asked to decide more than that. It is a DISK sync, so its
+ // answer says nothing about whether the local store can hold a value.
+ pthread_mutex_lock(&cn1ContinuityStoreLock);
+ @try {
+ NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore];
+ // The observer goes on independently of the probe, and this is the half that used to be
+ // missing. Registering for a notification is local: it needs no connectivity and no
+ // successful synchronize, and the store object is the same singleton either way. Tying it
+ // to the probe meant an offline launch installed no observer at all -- and an application
+ // that only registers a SyncedStoreListener makes exactly ONE store call, from
+ // addChangeListener, so nothing ever asked again. Reconnecting produced no callback for
+ // the life of the process, which is the whole feature for that app.
+ //
+ // Latched separately from `store` for the reason the store is latched at all: two callers
+ // arriving together must not both register, or every remote change is delivered twice.
+ if (s != nil && cn1ContinuityStoreObserver == nil) {
+ 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];
+ }
+ if (store == nil && s != nil) {
+ // Retained on the strength of the store EXISTING. A store that is never entitled
+ // still holds values locally, and holding them beats discarding them: the entitled
+ // case recovers everything written meanwhile, and the unentitled case is no worse off
+ // than the nil this used to return.
+ store = [s retain];
+ }
+ if (!cn1ContinuitySyncEntitled && s != nil && [s synchronize]) {
+ // Latches SUCCESS only, like the store beside it. Once it has answered YES the
+ // question is settled -- an app cannot lose an entitlement while it runs -- and
+ // every later call skips the probe, so this is not a synchronize per store access.
+ cn1ContinuitySyncEntitled = YES;
+ }
+ } @catch (NSException *e) {
+ // Deliberately NOT "store = nil". The handler now covers calls that already resolved --
+ // it moved out of the store == nil guard when the observer stopped depending on the probe
+ // -- and this port is MRR, so nulling a retained store would both leak it and lose a
+ // working store because a later synchronize threw. A failed attempt simply leaves the
+ // state it found: still nil, so the next call tries again.
+ }
+ pthread_mutex_unlock(&cn1ContinuityStoreLock);
+ 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) {
+ // Called for the side effect as much as the value: resolving is what runs the probe, and the
+ // flag it sets is the answer. Reporting "there is a store object" instead would tell an
+ // unentitled app the feature works, and its values would sit on that device for ever.
+ NSUbiquitousKeyValueStore *resolved = cn1ContinuityStore();
+ return (resolved != nil && cn1ContinuitySyncEntitled) ? JAVA_TRUE : JAVA_FALSE;
+}
+
+// The size of one value already in the store, whatever KIND of value it is.
+//
+// This class only ever writes strings, so the quota check used to count strings and skip
+// everything else -- which counts an NSData, an array or a dictionary as ZERO. The store is not
+// only ours: an application that used NSUbiquitousKeyValueStore before adopting this API, or a
+// container shared with an app extension, holds values of every plist kind. Skipping them made
+// the check pass on a store already over its limit, which is precisely the case it exists to
+// catch -- the write is kept locally, the readback says yes, and it never propagates.
+static NSUInteger cn1ContinuityValueBytes(id value) {
+ if (value == nil) {
+ return 0;
+ }
+ if ([value isKindOfClass:[NSString class]]) {
+ return [((NSString *)value) lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ }
+ if ([value isKindOfClass:[NSData class]]) {
+ return [((NSData *)value) length];
+ }
+ if ([value isKindOfClass:[NSNumber class]] || [value isKindOfClass:[NSDate class]]) {
+ // A scalar, whose encoded size is a handful of bytes whatever it holds.
+ return 16;
+ }
+ // An array or a dictionary. Serializing is the only way to ask how big a nested plist is,
+ // and the alternative this replaces was to call it nothing. Wrapped in an array because a
+ // bare root is not a property list for every format.
+ NSData *encoded = [NSPropertyListSerialization
+ dataWithPropertyList:[NSArray arrayWithObject:value]
+ format:NSPropertyListBinaryFormat_v1_0
+ options:0
+ error:NULL];
+ return encoded != nil ? [encoded length] : 0;
+}
+
+JAVA_BOOLEAN 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 JAVA_FALSE;
+ }
+ JAVA_BOOLEAN result = JAVA_FALSE;
+ POOL_BEGIN();
+ NSString *k = toNSString(CN1_THREAD_STATE_PASS_ARG key);
+ NSString *v = toNSString(CN1_THREAD_STATE_PASS_ARG value);
+ // The QUOTA first, because past it the store keeps the value locally and simply declines to
+ // upload it -- the readback below then says yes to a write that will never reach another
+ // device, and SyncedStore.put documents the opposite: false when "a key count or a size past
+ // what it allows". Apple's published maxima for NSUbiquitousKeyValueStore are 1 MB in total
+ // and 1024 keys.
+ //
+ // The measurement is an APPROXIMATION and is deliberately generous: UTF-8 bytes of the keys
+ // plus the size of every value the store already holds, of whatever plist kind. Apple does
+ // not publish how it counts, so the risk to avoid is refusing a write the platform would
+ // have taken -- the check only fires past the documented maximum, not near it.
+ NSDictionary *held = [store dictionaryRepresentation];
+ NSUInteger bytes = 0;
+ NSUInteger count = 0;
+ for (NSString *existing in held) {
+ if ([existing isEqualToString:k]) {
+ // Replaced, not added: its current size does not count towards the new total.
+ continue;
+ }
+ count++;
+ bytes += [existing lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ bytes += cn1ContinuityValueBytes([held objectForKey:existing]);
+ }
+ count++;
+ NSUInteger keyBytes = [k lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ bytes += keyBytes;
+ bytes += [v lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
+ // The PER-KEY maximum as well as the totals. Apple publishes 64 UTF-8 bytes for a key
+ // alongside the 1 MB and 1024-key figures, and only the two totals were being checked -- so
+ // a long key sailed through a nearly empty store, went to setString:forKey:, and left put()
+ // reporting a cross-device write it cannot deliver. The local readback below cannot see the
+ // difference, which is precisely why this check exists.
+ if (count > 1024 || bytes > 1048576 || keyBytes > 64) {
+ // Refused rather than written. A value the store keeps and never propagates is the one
+ // outcome an application cannot detect for itself, and it is exactly when it needs its
+ // own fallback.
+ POOL_END();
+ return JAVA_FALSE;
+ }
+ [store setString:v forKey:k];
+ // Asked for, not waited on and NOT reported. The system syncs on its own schedule and this
+ // only moves it along; its answer is about the STORE -- whether this build is entitled to one
+ // that follows the user -- which continuitySyncedStoreSupported reports and which this call
+ // is not being asked. ANDing it into the result here was the last place the entitlement probe
+ // still decided the fate of a local write: a transient NO made put() report failure for a
+ // value the store was holding and would have propagated later.
+ [store synchronize];
+ // The READBACK is the answer, and it is exactly what SyncedStore.put documents -- "true when
+ // the store holds the value afterwards". It is also the only part that can be established
+ // from in here: a store at its key or size limit drops the write while reporting nothing, and
+ // whether iCloud goes on to propagate it is not knowable from inside this call.
+ //
+ // Nor is the ENTITLEMENT ANDed in here, which a review asked for on the grounds that a build
+ // with ios.continuity.sync=false keeps a local store and so reports a write that can never
+ // leave the device. Both halves of that are true and the conclusion does not follow.
+ //
+ // An application deciding whether to offer the feature asks isSyncedStoreSupported(), and that
+ // already answers NO for an unentitled build -- it returns `resolved != nil &&
+ // cn1ContinuitySyncEntitled` precisely so a build without the entitlement cannot mistake
+ // itself for one that has it. The fallback the review is worried about is selected by that
+ // call, not by this one.
+ //
+ // And put() documents its answer as "true when the store holds the value afterwards", which
+ // is the only thing establishable from in here: whether iCloud goes on to propagate is not.
+ // Gating this on the entitlement is the same mistake that was removed from three layers at
+ // once -- IOSNative.m, IOSContinuityBridge and SyncedStore -- where it made every call
+ // unreachable and a transient probe answer report failure for a value the store was holding
+ // and would have propagated. Putting it back in one of them restores a third of that bug.
+ NSString *back = [store stringForKey:k];
+ if (back != nil && [back isEqualToString:v]) {
+ result = JAVA_TRUE;
+ }
+ POOL_END();
+ return result;
+}
+
+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;
+}
+JAVA_BOOLEAN 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) {
+ return JAVA_FALSE;
+}
+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_BOOLEAN com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT key, JAVA_OBJECT value) {
+ return com_codename1_impl_ios_IOSNative_continuitySyncedStorePut___java_lang_String_java_lang_String(CN1_THREAD_STATE_PASS_ARG instanceObject, key, value);
+}
+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..b1dfbabba56
--- /dev/null
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java
@@ -0,0 +1,206 @@
+/*
+ * 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;
+ }
+
+ @Override
+ public void setCallback(ContinuityCallback callback) {
+ IOSContinuityCallbacks.setCallback(callback);
+ }
+
+ @Override
+ public boolean isContinuationSupported() {
+ return supported;
+ }
+
+ @Override
+ 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);
+ }
+ }
+
+ @Override
+ public void clearContinuation() {
+ if (!supported) {
+ return;
+ }
+ try {
+ nativeInterface.continuityClear();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ @Override
+ public boolean isSyncedStoreSupported() {
+ if (!supported) {
+ return false;
+ }
+ try {
+ return nativeInterface.continuitySyncedStoreSupported();
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ /// Gated on the PORT, not on the probe.
+ ///
+ /// isSyncedStoreSupported() answers whether this build is entitled to a store that follows
+ /// the user, which the native side learns from a synchronize. That is the right answer for
+ /// an application deciding whether to offer the feature, and the wrong gate for the calls
+ /// themselves: the store is a LOCAL persistent one, so reads and writes work and propagate
+ /// later, and refusing them here made the native side's retained store unreachable -- values
+ /// already cached were reported absent and writes were dropped, which is the whole of what
+ /// retaining it was for.
+ ///
+ /// The natives answer for themselves when there is no store at all.
+ @Override
+ public boolean syncedStorePut(String key, String value) {
+ if (!supported) {
+ return false;
+ }
+ try {
+ return nativeInterface.continuitySyncedStorePut(key, value);
+ } catch (Throwable t) {
+ Log.e(t);
+ return false;
+ }
+ }
+
+ @Override
+ public String syncedStoreGet(String key) {
+ if (!supported) {
+ return null;
+ }
+ try {
+ return nativeInterface.continuitySyncedStoreGet(key);
+ } catch (Throwable t) {
+ Log.e(t);
+ return null;
+ }
+ }
+
+ @Override
+ public void syncedStoreRemove(String key) {
+ if (!supported) {
+ return;
+ }
+ try {
+ nativeInterface.continuitySyncedStoreRemove(key);
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+
+ @Override
+ public String[] syncedStoreKeys() {
+ if (!supported) {
+ 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];
+ }
+ // The default parser is right HERE, unlike the continuation hop in
+ // IOSContinuityCallbacks: this document is {"keys":[...]} written by our own native
+ // side, its elements are strings, and the loop below discards anything that is not
+ // one. There is no boolean to mistype and no null whose absence changes a meaning.
+ 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..3a6a6a4daeb
--- /dev/null
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java
@@ -0,0 +1,304 @@
+/*
+ * 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 com.codename1.ui.Display;
+
+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 {
+ /// The framework's inbound seam, owned by the event thread.
+ ///
+ /// Read and written from the platform's thread as well as the event thread, and PLAIN --
+ /// neither volatile nor guarded, because this project does not allow either here.
+ ///
+ /// The doc here used to say the platform's arrival is marshalled and everything below is
+ /// ordinary EDT code. That stopped being true when nativeContinuation() began calling
+ /// deliverToFramework() inline: the answer is owed to the OS synchronously and the framework
+ /// binds an arrival to the generation it arrived in, so the hand-over happens on whatever
+ /// thread the activity came in on, while the event thread may be installing a callback.
+ ///
+ /// Four reviews have now asked for safe publication of these fields, the last of them
+ /// observing correctly that the re-read in deliverToFramework() settles the ORDERING and
+ /// establishes no happens-before edge. Both remedies it offers are closed to this file:
+ /// `volatile` is on the project's forbidden PMD list (AvoidUsingVolatile) and this port is
+ /// one of the gated modules, and a lock would have to span the delivery to add anything --
+ /// which means holding it while calling into the framework and the event thread, trading a
+ /// missed activity for a deadlock.
+ ///
+ /// So the ordering is closed and the visibility is not, deliberately. What that leaves is a
+ /// continuation delivered late rather than never: the arrival stays in the pending pair
+ /// below, and setCallback() drains it inline at the next enable(), disable(), clear() or
+ /// bridge swap.
+ private static ContinuityCallback callback;
+
+ /// Written once by the class initializer, which every thread's first touch of this class
+ /// happens after, so it needs no lock of its own.
+ 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;
+ if (c == null || type == null) {
+ return;
+ }
+ // A continuation that cold-launched the app reaches 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.
+ boolean claimed = false;
+ try {
+ claimed = c.continuationReceived(type, parse(json));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ if (claimed) {
+ // Cleared only once a callback has actually TAKEN it. The callback can legitimately
+ // decline: SyncedStore.addChangeListener installs one without enabling continuity --
+ // a key/value store is not consent to restore a route stack -- and on a cold launch
+ // that can happen before the application's init() calls enable(). Clearing regardless
+ // meant the launch activity was erased by the refusal, and the enable() moments later
+ // had nothing left to deliver.
+ pendingType = null;
+ pendingJson = null;
+ }
+ }
+
+ /// 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;
+ }
+ // Answered on the platform's thread, from the activity type alone, because the delegate
+ // needs the answer now: it decides whether the activity falls through to the intents
+ // branch beside it, and one this app is about to act on must not.
+ //
+ // Declined only on a POSITIVE mismatch. Asking for the expected type can fail this early,
+ // before the stub has published package_name, and treating "cannot tell" as "not ours"
+ // would decline the framework's own cold launch -- the one case the whole feature exists
+ // for. Belt and braces in any case: the delegate already matches the exact type the build
+ // resolved, and this guards the app whose generated project predates that key.
+ String expected = expectedTypeOrNull();
+ if (expected != null && !expected.equals(activityType)) {
+ return false;
+ }
+ if (!Display.isInitialized()) {
+ // The event thread does not exist yet, so there is nothing to marshal to. Parked here
+ // and drained by setCallback; the EDT is started after these writes, which publishes
+ // them to it.
+ pendingType = activityType;
+ pendingJson = userInfoJson;
+ return true;
+ }
+ // Handed over DIRECTLY, on this thread, and the framework does its own marshalling.
+ //
+ // This used to queue and answer true. The queue is where the arrival lost its place in
+ // time: Continuity binds a continuation to the lifecycle generation it arrived in, and a
+ // logout already sitting on the event queue runs first -- so the generation captured
+ // after this hop is the one AFTER the logout, and the previous account's state is
+ // restored and persisted by a session that promised nothing from before it survives.
+ // Calling through means the generation is read at the instant the activity actually
+ // arrived.
+ //
+ // The answer is the framework's own rather than an unconditional true, which is also what
+ // the delegate should be told.
+ return deliverToFramework(activityType, userInfoJson);
+ }
+
+ /// Hands an arrival to the framework, or holds it. Called on whatever thread the activity
+ /// arrived on; the framework marshals what it needs to.
+ private static boolean deliverToFramework(String activityType, String userInfoJson) {
+ // A plain read, and not a synchronized or volatile one. Two reviews have asked for safe
+ // publication here and the answer is the re-read at the bottom of this method rather than
+ // a lock -- see the comment there for why that closes the ordering, and why a lock would
+ // have to span a call into the event thread to add anything.
+ ContinuityCallback c = callback;
+ boolean claimed = false;
+ if (c != null) {
+ try {
+ claimed = c.continuationReceived(activityType, parse(userInfoJson));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ if (claimed) {
+ return true;
+ }
+ // Held, because DECLINED is not the same as "not ours". A callback is installed by
+ // SyncedStore.addChangeListener() as well as by Continuity.enable(), and the store
+ // listener deliberately leaves continuity disabled -- so an app that registers one before
+ // enabling has a live callback that answers false to everything. A continuation arriving
+ // in that window used to be handed over, refused, and dropped, and the enable() moments
+ // later had nothing to recover: registering an unrelated store listener turned a parked
+ // cold-launch continuation into a lost one.
+ pendingType = activityType;
+ pendingJson = userInfoJson;
+ // And then LOOK AGAIN, because the callback may have been installed since the read above.
+ //
+ // The interleaving is real and ordering, not visibility: this thread reads `callback` as
+ // null, the event thread installs one and finds pendingType still null, and only then does
+ // this thread store it. The activity is left behind by the one installation that would
+ // have drained it, and nothing else is coming -- setCallback() runs at enable(), disable(),
+ // clear() and a bridge swap, so an app that does none of those again has lost the Handoff
+ // rather than deferred it.
+ //
+ // Closed by re-reading rather than by locking. One of the two orders must hold: either the
+ // event thread reads pendingType after the write above and drains it, or this read happens
+ // after the install and delivers it here. A lock would have to span the delivery to add
+ // anything, and that means calling into the framework -- and the event thread -- while
+ // holding it, which trades a lost activity for a deadlock.
+ ContinuityCallback late = callback;
+ if (late == null || late == c) { //NOPMD CompareObjectsWithEquals
+ // Nothing arrived in the window, or the same one that already declined it.
+ return false;
+ }
+ boolean lateClaimed = false;
+ try {
+ lateClaimed = late.continuationReceived(activityType, parse(userInfoJson));
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ if (lateClaimed) {
+ // Taken after all, so the port lets go -- but only of THIS activity. A newer one
+ // stored by another arrival in the meantime is not ours to clear.
+ if (activityType != null && activityType.equals(pendingType)) {
+ pendingType = null;
+ pendingJson = null;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /// The synced store changed on another of the user's devices.
+ public static void nativeSyncedStoreChanged() {
+ if (dceGuard) {
+ return;
+ }
+ if (!Display.isInitialized()) {
+ return;
+ }
+ Display.getInstance().callSerially(new Runnable() {
+ @Override
+ public void run() {
+ ContinuityCallback c = callback;
+ if (c == null) {
+ return;
+ }
+ try {
+ c.syncedStoreChanged();
+ } catch (Throwable t) {
+ Log.e(t);
+ }
+ }
+ });
+ }
+
+ /// This app's continuity activity type, or null when it cannot be determined yet.
+ ///
+ /// Null rather than a guess: `Continuity.getActivityType()` substitutes a placeholder package
+ /// when the property is missing, and a placeholder compared against a real activity type is a
+ /// mismatch that reads as certainty.
+ private static String expectedTypeOrNull() {
+ try {
+ String pkg = Display.getInstance().getProperty("package_name", null);
+ if (pkg == null || pkg.length() == 0) {
+ return null;
+ }
+ return pkg + ".continuity";
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+
+ private static Map parse(String json) {
+ if (json == null || json.length() == 0) {
+ return new HashMap();
+ }
+ try {
+ // CONFIGURED like StateCodec.fromJson, which is the reference: this is the same
+ // document arriving through the other door, and a parser set up differently changes
+ // what the application receives.
+ //
+ // useBoolean, because the default answers a raw JSON true or false with the strings
+ // "true" and "false". Harmless for the tagged form this framework writes -- "b:true"
+ // is a string either way -- and wrong for an untagged compatibility document from a
+ // hand-written sender: the payload reaches the listeners and the provider with
+ // Strings where booleans were sent, passes validation because a String is a
+ // representable type, and is acknowledged.
+ //
+ // includeNulls, because dropping a null here is worse than refusing it. fromMap()
+ // refuses a null nested in a list -- a property list cannot carry one, and the iOS
+ // sanitiser shifts every index after it -- but only if it can see it. Dropped by the
+ // parser, the list simply arrives one element shorter, which is the corruption that
+ // check exists to prevent.
+ JSONParser parser = new JSONParser();
+ parser.setUseBooleanInstance(true);
+ parser.setIncludeNullsInstance(true);
+ Map parsed = parser.parseJSON(new java.io.StringReader(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 693d4a8652d..5fde87064fe 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -494,6 +494,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 551f36d5eed..f9ce3f14197 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java
@@ -1417,6 +1417,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, answering whether the store holds it afterwards. */
+ native boolean 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/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..c53fb518b1a
--- /dev/null
+++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.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);
+ } else {
+ // Declining is a decision, and it has to be recorded. Returning false alone
+ // only suppresses the state for THIS run -- false also means "keep it, I will
+ // prompt again later" -- so without this the relay's unchanged document asks
+ // the same question after every relaunch.
+ Continuity.acknowledge(state);
+ }
+ // Consumed either way: the decision has been made here, and recorded either way.
+ 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() {
+ // Both, and in this order. clear() forgets the account's data; disable() closes the
+ // door behind it. clear() on its own leaves continuity ON, so a continuation that
+ // arrives while your login screen is up is a valid arrival to a framework that is
+ // still listening -- and the signed-out account's routes and payload get restored
+ // over it.
+ Continuity.clear();
+ Continuity.disable();
+ }
+
+ public void onLogin() {
+ // And open it again. Continuity stays off until you say otherwise, which is what
+ // makes the gap above safe.
+ Continuity.enable();
+ }
+ // 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..e950f171f3c
--- /dev/null
+++ b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties
@@ -0,0 +1,5 @@
+// Generated from docs/developer-guide source blocks. Edit the guide snippets here, not inline.
+
+// 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..0c092a4bf8d
--- /dev/null
+++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc
@@ -0,0 +1,313 @@
+== 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()` *and* `Continuity.disable()` on your logout path, and
+`Continuity.enable()` on your login 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 -- and anything still queued for the
+relay would have gone out later under the next account's credentials, because a
+relay reads its token when the request runs:
+
+[source,java]
+----
+include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=logout,indent=0]
+----
+
+WARNING: `clear()` alone isn't a logout. It forgets the account's data -- the
+stored checkpoint, the advertised activity, anything queued for the relay -- and
+it leaves continuity switched on by design, because forgetting state and turning
+the feature off are two different things and an app is entitled to do the first
+without the second. That means a continuation arriving while your login screen is
+up reaches a framework that's still listening, and the signed-out account's routes
+and payload are restored over it. `disable()` is what closes that gap, and
+`enable()` at login is what reopens it.
+
+=== 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 the
+store actually took the value -- checked by reading it back, not assumed. 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.sync` | unset | Whether this project wants the iCloud key-value store. Left unset the build decides from the bytecode. Set `false` to drop the entitlement; set `true` to declare it, which is what lets the signing preflight check your profile before the build is sent.
+|===
+
+Everything is automatic by default. 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 51e608299b8..f38f7e6445a 100644
--- a/docs/website/data/port_status.json
+++ b/docs/website/data/port_status.json
@@ -464,6 +464,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/docs/website/workers/exp004-telemetry/test/integration.mjs b/docs/website/workers/exp004-telemetry/test/integration.mjs
index 9aa0791f441..132adcd7d23 100644
--- a/docs/website/workers/exp004-telemetry/test/integration.mjs
+++ b/docs/website/workers/exp004-telemetry/test/integration.mjs
@@ -167,28 +167,82 @@ async function expectStatus(response, expected, what) {
`${what}: expected ${expected}, got ${response.status} with body ${body}`);
}
+/*
+ * True when a response is the edge saying it has no route for us yet, rather
+ * than our worker answering.
+ *
+ * The distinction is the one collectNotReady already draws and for the same
+ * reason: a 404 carrying Cloudflare's HTML error page is produced BEFORE the
+ * worker runs, so nothing was read, counted or deduplicated. A 5xx is not this
+ * -- bindings that are still initializing answer after the request reached the
+ * handler -- so it is deliberately not included here.
+ */
+async function isUnroutedEdgeResponse(response) {
+ if (response.status !== 404) {
+ return false;
+ }
+ const contentType = response.headers.get("content-type") || "";
+ return !contentType.includes("application/json");
+}
+
+/*
+ * Posts, retrying only while the edge has not routed us yet.
+ *
+ * waitFor gates the suite on one healthy round, and that is not enough on its
+ * own: propagation is per-request and neither monotonic nor global, so a later
+ * POST can still land on an edge node that has not caught up. That is what
+ * failed here -- the "unknown event name" assertion got Cloudflare's 404 page
+ * instead of the worker's 400, on a run whose readiness probe had already
+ * passed.
+ *
+ * Safe to repeat, and ONLY in this exact case. readSnapshot retries because a
+ * GET changes nothing; the objection to retrying a POST is that it could count
+ * twice. An unrouted 404 cannot: the request never reached the worker. Any
+ * response the worker itself produced -- including every rejection the suite
+ * asserts on -- is returned untouched on the first attempt.
+ */
async function post(baseUrl, body, origin = "https://www.codenameone.com") {
- return fetch(`${baseUrl}/api/exp004/collect`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Origin: origin,
- "Sec-Fetch-Site": "same-origin",
- },
- body: JSON.stringify({ occurred_at: Date.now(), ...body }),
- });
+ const payload = JSON.stringify({ occurred_at: Date.now(), ...body });
+ const deadline = Date.now() + 30_000;
+ for (;;) {
+ const response = await fetch(`${baseUrl}/api/exp004/collect`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: origin,
+ "Sec-Fetch-Site": "same-origin",
+ },
+ body: payload,
+ });
+ if (!await isUnroutedEdgeResponse(response) || Date.now() >= deadline) {
+ return response;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
}
async function enroll(baseUrl, sessionKey, arm) {
- const response = await fetch(`${baseUrl}/api/exp004/session`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Origin: "https://www.codenameone.com",
- "Sec-Fetch-Site": "same-origin",
- },
- body: JSON.stringify({ session_key: sessionKey, arm }),
- });
+ // Same unrouted-edge retry as post(): enrolment is a POST on a second route,
+ // and a route propagates on its own schedule, so gating on /collect being
+ // live says nothing about /session.
+ const body = JSON.stringify({ session_key: sessionKey, arm });
+ const deadline = Date.now() + 30_000;
+ let response;
+ for (;;) {
+ response = await fetch(`${baseUrl}/api/exp004/session`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Origin: "https://www.codenameone.com",
+ "Sec-Fetch-Site": "same-origin",
+ },
+ body,
+ });
+ if (!await isUnroutedEdgeResponse(response) || Date.now() >= deadline) {
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
await expectStatus(response, 201, `enroll ${sessionKey} into ${arm}`);
const payload = await response.json();
assert.match(payload.submission_token, /^[0-9a-f-]{36}$/);
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..482b5ea84c3 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,28 @@ 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.sync")
+ .group(HintGroup.IOS)
+ .type(HintType.BOOLEAN)
+ // NO default, because this hint has three states and def() can only describe two.
+ // Unset is its own answer -- the bytecode scan decides -- while an explicit true
+ // now DECLARES the store, forcing the entitlement and the provisioning preflight
+ // whatever the scan found. Declaring "true" here said the two were the same thing
+ // to everything that reads the catalog, so a project that had merely never set it
+ // was presented as having opted in, and the tooling would offer an iCloud
+ // entitlement the build would not have asked for. The doc below says which state
+ // does what.
+ .platform("ios")
+ .doc("Whether this project wants the iCloud key-value store behind "
+ + "com.codename1.continuity.sync. Left unset the build decides from the "
+ + "bytecode, which is usually what you want. Set false when the App ID "
+ + "has no iCloud capability and the app can live without a synced store: "
+ + "the entitlement is dropped and SyncedStore reports itself unsupported "
+ + "at runtime rather than the build failing to sign. Set true to say so "
+ + "explicitly, which is what lets the signing preflight check the profile "
+ + "before the build is sent. Handing work to a nearby device is "
+ + "unaffected either way -- 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 66da695f342..d484d519dc1 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.
@@ -2655,6 +2670,31 @@ 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.
+ //
+ // A cn1lib needs no separate pass, and must not get one. CN1BuildMojo merges
+ // every compile-classpath element into one jar-with-dependencies and submits
+ // that as dist.jar (blacklisting only codenameone-core and java-runtime), so
+ // library code reaches the server already indistinguishable from the app's
+ // own and is walked by this scan. Folding buildinRes in the way the call/VPN
+ // pair does would also be actively wrong here: Navigation calls
+ // Continuity.routeStackChanged, so the framework's own classes name this
+ // package, and LibraryClassPrefixScan only filters classes INSIDE the scanned
+ // prefix -- it would report usage for every app ever built and demand an
+ // iCloud entitlement that fails codesigning wherever the App ID lacks it.
+ 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.
@@ -4099,6 +4139,38 @@ 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.
+ // An explicit ios.continuity.sync=true is a DECLARATION, not only a veto. The hint
+ // documents itself as "set true to say so explicitly", and the signing preflight
+ // already reads it exactly that way -- it is how a project says it wants the store
+ // without that check having to read bytecode. The build ignored it unless the scan
+ // had already found the package, so a project that says so and whose usage the scan
+ // cannot see got neither the entitlement nor the define, while the preflight warned
+ // about a profile for a capability the build was never going to ask for.
+ //
+ // Both flags, because the scan sets both for the same reason its own comment gives:
+ // the store needs the native define as well as the entitlement, and an entitlement
+ // without the define is a SyncedStore that reports itself unsupported on a device.
+ //
+ // Only an explicit true does this. Unset still means "the bytecode decides", which is
+ // what keeps an app that merely hands work to a nearby device from being handed an
+ // iCloud entitlement its App ID may not carry.
+ if ("true".equals(request.getArg("ios.continuity.sync", null))) {
+ usesContinuity = true;
+ usesContinuitySync = true;
+ }
+ 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
@@ -5453,6 +5525,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
@@ -10934,7 +11038,11 @@ private static String resolveSettingsInValue(String value, Map a
private static final int MAX_SETTING_EXPANSIONS = 16;
/// One build setting, in either of the two spellings Xcode accepts for a reference.
- private static String replaceBuildSetting(String path, String name, String value) {
+ ///
+ /// Package-visible rather than private because MacNativeBuilder needs the SAME answer: the
+ /// Catalyst entitlement has to materialize the iOS bundle id, and hand-listing the spellings
+ /// there was how "$(CFBundleIdentifier)" got handled while "${CFBundleIdentifier}" did not.
+ static String replaceBuildSetting(String path, String name, String value) {
String out = path.replace("$(" + name + ")", value).replace("${" + name + "}", value);
return applyModifiers(out, name, value);
}
@@ -11582,6 +11690,83 @@ private void appendWalletExtensionRuby(StringBuilder sb, BuildRequest request, S
/// supported configuration silently useless: the content was findable, and tapping it did
/// nothing, because without this key iOS never continues the activity and
/// nativeSpotlightItemSelected is never reached.
+ /// Declares the continuity activity type where the app's own native code can read it.
+ ///
+ /// `NSUserActivityTypes` tells iOS which activities to offer; this tells the delegate which
+ /// of them is this framework's. It has to be a value the BUILD resolved, because the delegate
+ /// decides before any Java is running and cannot ask the framework -- and the obvious
+ /// substitute is wrong on the Mac slice: `DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER` makes
+ /// the Catalyst bundle id `.maccatalyst`, so a type derived there from the bundle id
+ /// would be `.maccatalyst.continuity` while every device publishes
+ /// `.continuity`. The Mac plist is generated from the finished iOS one, so writing
+ /// it once here gives both slices the same string.
+ ///
+ /// #### Parameters
+ ///
+ /// - `inject`: the plist fragment being built
+ ///
+ /// - `continuityType`: the resolved activity type, or null when the app does not use the
+ /// feature
+ ///
+ /// #### Returns
+ ///
+ /// the fragment, with the key added when one is needed
+ ///
+ /// #### Throws
+ ///
+ /// - `BuildException`: when the project injects a different type of its own
+ static String withContinuityActivityType(String inject, String continuityType)
+ throws BuildException {
+ if (continuityType == null) {
+ return inject;
+ }
+ // Refused rather than left alone when it disagrees, exactly as CN1DocumentsAppGroup is,
+ // because this is not a hint. An injected key naming a different type has the delegate
+ // rejecting the application's own continuations while iOS goes on offering them.
+ // Two live root declarations of the key, refused for the reason NSUserActivityTypes and
+ // UIApplicationSceneManifest already are: a property list takes the LAST of a duplicated
+ // key while every lookup here answers with the first, so an agreeing first declaration
+ // would be left alone while the delegate reads a different second one -- the generated
+ // array advertising one type and the native side accepting another, which is Handoff
+ // silently dead. The scalar case was left out of the round that refused the array; it is
+ // the same trap on the neighbouring key.
+ if (inject != null
+ && plistMemberDuplicated(inject, 0, inject.length(), "CN1ContinuityActivityType")) {
+ throw new BuildException("ios.plistInject declares CN1ContinuityActivityType twice. A "
+ + "property list takes the last of a duplicated key, so this build cannot "
+ + "tell which value the delegate will read. Leave one of them.");
+ }
+ String injected = topLevelPlistString(inject, "CN1ContinuityActivityType");
+ if (injected == null && declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) {
+ // Declared, but not as a string. topLevelPlistString answers null for an array, a
+ // dict or anything else, so this used to fall through both branches: the value was
+ // left alone because something was declared, and ours was not added because the key
+ // was present. The build then succeeded and the delegate found a value that is not an
+ // NSString, which it treats as absent -- so every continuation quietly bypassed the
+ // handler on a build that looked configured.
+ throw new BuildException("ios.plistInject declares CN1ContinuityActivityType with a "
+ + "non-string value. It has to be the activity type this build publishes, '"
+ + continuityType + "', or be left out so the build writes it.");
+ }
+ if (injected != null && !injected.equals(continuityType)) {
+ throw new BuildException("ios.plistInject sets CN1ContinuityActivityType to '"
+ + injected + "' while this build publishes '" + continuityType
+ + "'. The application would refuse its own continuations. Remove the "
+ + "injected key.");
+ }
+ // Only an AGREEING declaration reaches here. A value that differs was refused by the
+ // check above and a non-string one by the check above that, so this cannot leave the
+ // fragment advertising one type through NSUserActivityTypes while the delegate reads
+ // another -- both keys are written from the same resolved string a few lines apart in the
+ // caller, and a declaration that disagrees with it fails the build rather than being
+ // stood aside for.
+ if (declaresTopLevelPlistKey(inject, "CN1ContinuityActivityType")) {
+ return inject;
+ }
+ return inject + "\nCN1ContinuityActivityType "
+ + xmlEscape(continuityType) + " ";
+ }
+
static String withSpotlightContinuation(String inject, boolean usesIntents) {
if (!usesIntents || inject.contains("CoreSpotlightContinuation")) {
return inject;
@@ -11601,6 +11786,21 @@ static String withSpotlightContinuation(String inject, boolean usesIntents) {
/// `IOSAppIntentsBuilder.publishesUserActivity` for why advertising the rest is not
/// harmlessly generous.
static String userActivityTypesKey(List> intents) {
+ return userActivityTypesKey(intents, null);
+ }
+
+ /// The same key, carrying the continuity activity type alongside the intent ids.
+ ///
+ /// One key, not two. `NSUserActivityTypes` appears once in a property list or iOS reads the
+ /// file unpredictably, so the two features that contribute to it -- app intents and
+ /// continuity -- have to meet here rather than each emitting their own. An app that uses both
+ /// is the ordinary case, not a corner.
+ ///
+ /// #### Parameters
+ ///
+ /// - `intents`: the app's intent declarations, possibly empty
+ /// - `continuityType`: this app's continuity activity type, or null when it uses none
+ static String userActivityTypesKey(List> intents, String continuityType) {
StringBuilder types = new StringBuilder();
for (Map intent : intents) {
Object id = intent.get("id");
@@ -11608,6 +11808,9 @@ static String userActivityTypesKey(List> intents) {
types.append("").append((String) id).append(" ");
}
}
+ if (continuityType != null && continuityType.length() > 0) {
+ types.append("").append(continuityType).append(" ");
+ }
if (types.length() == 0) {
// An app whose only assistant-exposed intent is destructive reaches here and
// contributes nothing. Writing the key with an empty array would state that the app
@@ -11618,18 +11821,375 @@ static String userActivityTypesKey(List> intents) {
return "\nNSUserActivityTypes " + types + " ";
}
- static String mergeUserActivityTypes(String inject, List> intents) {
+ /// The index of the first `` element naming `key` that is NOT inside an XML comment.
+ ///
+ /// `plistKeyIndex` reads structure but not liveness, so it answers with a declaration the
+ /// project commented out. Every use that goes on to EDIT what it found needs this instead.
+ ///
+ /// @param plist the fragment
+ /// @param key the key name
+ /// @return the index of the live key element, or -1
+ /// The dictionary nesting depth of `at`, counting live tags only.
+ ///
+ /// The fragment `ios.plistInject` supplies is a sequence of the ROOT dictionary's own
+ /// members, so depth 0 is the plist's root. A member's value may itself be a ``, and a
+ /// key inside one belongs to that dictionary rather than to the plist. iOS reads
+ /// NSUserActivityTypes at the root and nowhere else, so treating a nested one as the app's
+ /// declaration merged the continuity type into a dictionary nobody reads for it AND skipped
+ /// appending the root key -- an app whose Handoff simply never gets advertised, with an
+ /// unrelated property quietly rewritten, and nothing logged either way.
+ static int plistDictDepth(String plist, int at) {
+ int depth = 0;
+ int i = 0;
+ while (i < at) {
+ int open = plist.indexOf('<', i);
+ if (open < 0 || open >= at) {
+ break;
+ }
+ // The SHARED scanner, not a local "is this a comment" test. A CDATA section, a
+ // comment, a processing instruction and a declaration can all carry text shaped like
+ // an element, and a plist parser reads none of it as markup. A hand-rolled comment
+ // check got this wrong in the way that matters: " ]]>" ended at the
+ // FIRST ">", so the "" written inside the character data was counted as real
+ // structure, a following root key was classified as nested, and the branch above
+ // appended a SECOND NSUserActivityTypes -- the duplicate key this whole area exists
+ // to prevent.
+ int skipped = WatchNativeBuilder.skipMarkupBefore(plist, open, i);
+ if (skipped < 0) {
+ // Unterminated: nothing after it can be read reliably, so stop counting rather
+ // than guess, and answer with the depth established so far.
+ break;
+ }
+ if (skipped != open) {
+ i = skipped;
+ continue;
+ }
+ int end = plist.indexOf('>', open);
+ if (end < 0) {
+ break;
+ }
+ String tag = plist.substring(open, end + 1);
+ if ("dict".equals(plistTagName(tag))) {
+ if (tag.startsWith("")) {
+ depth--;
+ } else if (!tag.endsWith("/>")) {
+ // " " opens and closes in one element, so it changes nothing.
+ depth++;
+ }
+ }
+ i = end + 1;
+ }
+ return depth;
+ }
+
+ /// The element name of a tag, without the closing slash or any attributes.
+ static String plistTagName(String tag) {
+ int from = tag.startsWith("") ? 2 : 1;
+ int to = from;
+ while (to < tag.length()) {
+ char c = tag.charAt(to);
+ if (c == '>' || c == '/' || c == ' ' || c == '\t' || c == '\r' || c == '\n') {
+ break;
+ }
+ to++;
+ }
+ return tag.substring(from, to);
+ }
+
+ /// The first live key at the fragment's own level, skipping any a nested dictionary owns.
+ ///
+ /// Both the branch that decides whether to append and the merge itself have to use this, or
+ /// they disagree: one sees a declaration the other cannot find, which is how a key gets
+ /// appended twice or an array gets merged into that iOS never reads.
+ /// Whether `at` is a position a plist parser would read as markup.
+ ///
+ /// The same four constructs `skipMarkupBefore` knows, walked forward rather than guessed at
+ /// backwards: the old `lastIndexOf("` span.
+ ///
+ /// An unterminated comment swallows the rest of the fragment, which is what a parser does
+ /// with it too -- see plistWithoutComments.
+ static boolean insideComment(String plist, int at) {
+ int open = plist.lastIndexOf("", open + 4);
+ return close < 0 || close > at;
+ }
+
+ /// The index of the element that is the key's IMMEDIATE value, or -1.
+ ///
+ /// Whitespace and live comments are stepped over, because
+ /// `NSUserActivityTypes ` is a fragment a person writes and a
+ /// plist parser reads the array as the key's value regardless. Anything else stops the walk:
+ /// scanning onwards for the next element of the shape we want is what let a merge reach past
+ /// a NON-array value and insert into some later key's array instead, corrupting a property
+ /// this code was never asked about.
+ ///
+ /// @param plist the fragment
+ /// @param keyIndex the index of the `` element
+ /// @return the index of the value element, or -1
+ static int immediateValueIndex(String plist, int keyIndex) {
+ if (plist == null || keyIndex < 0) {
+ return -1;
+ }
+ // plistKeyEnd, not a literal search for " " -- at which point a raw search ends the key
+ // inside the comment, decides the value is not an array, and drops every activity type
+ // without a word. The structural helper resolves the element the way the branch that
+ // decided to merge already did.
+ int at = plistKeyEnd(plist, keyIndex);
+ if (at < 0) {
+ return -1;
+ }
+ for (;;) {
+ while (at < plist.length() && Character.isWhitespace(plist.charAt(at))) {
+ at++;
+ }
+ if (at >= plist.length()) {
+ return -1;
+ }
+ // The SHARED scanner, not a local comment test. A processing instruction, a
+ // declaration and a CDATA section are every bit as invisible to a plist parser as a
+ // comment is, and it steps over all of them on its way to the key's value. Stopping
+ // on one made immediateValueIndex answer with the "", so both the expansion and the
+ // merge decided the value was not an array and dropped every activity type without a
+ // word. An unterminated construct still yields -1: nothing after it can be read.
+ int skipped = WatchNativeBuilder.skipMarkupBefore(plist, at, at);
+ if (skipped < 0) {
+ return -1;
+ }
+ if (skipped != at) {
+ at = skipped;
+ continue;
+ }
+ return at;
+ }
+ }
+
+ /// Rewrites a self-closing `NSUserActivityTypes` array into an open/close pair.
+ ///
+ /// ` ` is the ordinary XML spelling of an empty array and a plist parser reads it
+ /// exactly as ` `. `mergeUserActivityTypes` looks for the literal pair, so
+ /// without this an application that declared the key that way took the merge branch and had
+ /// every id silently dropped -- the one outcome worse than a duplicate key, because nothing
+ /// says so until Handoff does not work on a device.
+ ///
+ /// Only this key's array is touched, and only when it is the key's immediate value: another
+ /// key's empty array is none of this method's business.
+ ///
+ /// @param inject the plist fragment the application supplied
+ /// @return the fragment, with this one array expanded when it needed it
+ static String expandEmptyUserActivityArray(String inject) {
+ if (inject == null) {
+ return null;
+ }
+ int key = firstLiveRootIndex(inject, "NSUserActivityTypes");
+ if (key < 0) {
+ return inject;
+ }
+ int at = immediateValueIndex(inject, key);
+ if (at < 0 || !inject.startsWith("', at);
+ if (close < 0 || inject.charAt(close - 1) != '/') {
+ // Already an open/close pair, which the merge understands as it is.
+ return inject;
+ }
+ return inject.substring(0, at) + " " + inject.substring(close + 1);
+ }
+
+ /// Whether an array's text already lists `value` as a LIVE entry.
+ ///
+ /// Through the shared live scanner, so an entry the project commented out does not count as
+ /// declared. It is the array iOS reads that has to carry the type, and a disabled line looks
+ /// identical to a raw text search.
+ static boolean listsLiveString(String arrayText, String value) {
+ return plistIndexOfLive(arrayText, "" + value + " ", 0) >= 0;
+ }
+
+ static String mergeUserActivityTypes(String inject, List> intents)
+ throws BuildException {
+ return mergeUserActivityTypes(inject, intents, null);
+ }
+
+ /// Refuses a build whose continuity type has nowhere to go.
+ ///
+ /// Returning the fragment untouched is the right answer for the intents-only merge -- writing
+ /// a SECOND NSUserActivityTypes key produces a plist iOS reads unpredictably, which is worse
+ /// than the ids being absent -- but it is the wrong answer once a continuity type depends on
+ /// that array. The caller has already seen the key and so writes no array of its own, so the
+ /// type reaches no array at all: the build succeeds, CN1ContinuityActivityType is present,
+ /// and Handoff is never advertised, with nothing anywhere saying so.
+ ///
+ /// The declaration is malformed either way -- iOS requires an array here -- so this is not
+ /// this build's failure to report in general, and an intents-only project keeps the behaviour
+ /// it has today. It is reported when continuity depends on it, for the same reason
+ /// withContinuityActivityType refuses a CN1ContinuityActivityType that is not a string: a
+ /// feature that is silently inert on the device is the one outcome worth failing a build for.
+ private static void requireArrayForContinuity(String continuityType, String what)
+ throws BuildException {
+ if (continuityType == null || continuityType.length() == 0) {
+ return;
+ }
+ throw new BuildException("ios.plistInject declares NSUserActivityTypes with a value that "
+ + what + ". This build publishes the continuity activity type '" + continuityType
+ + "', which iOS only reads from an under that key. Declare it as an "
+ + "array -- the build adds the type to an array it can find -- or leave the key "
+ + "out so the build writes the whole array itself.");
+ }
+
+ /// Refuses an App Intent whose id IS the continuity activity type.
+ ///
+ /// An intent's id is published as its NSUserActivity activityType verbatim -- see
+ /// userActivityTypesKey, which appends it unchanged -- and the continuity type goes into the
+ /// same array. Declare both and the array carries the string twice, which is untidy; the part
+ /// that matters is that the native delegate has nothing left to tell them apart, so whichever
+ /// handler looks first claims an activity meant for the other. Handoff resuming into an
+ /// intent's screen, or an intent invocation restoring a route stack, with nothing logged.
+ ///
+ /// Refused rather than renamed. The id is the application's, published to the system and
+ /// possibly already donated on a device, so a build that quietly changed it would break the
+ /// donations already out there; and the continuity type is what the delegate compiles in.
+ /// Naming the collision is the only repair that leaves both meanings intact.
+ ///
+ /// #### Parameters
+ ///
+ /// - `intents`: the parsed intents manifest
+ ///
+ /// - `continuityType`: the resolved activity type, or null when the app does not use it
+ ///
+ /// #### Throws
+ ///
+ /// - `BuildException`: when an intent publishes the continuity type as its own
+ static void requireNoIntentClaimsTheContinuityType(List> intents,
+ String continuityType) throws BuildException {
+ if (intents == null || continuityType == null || continuityType.length() == 0) {
+ return;
+ }
+ for (Map intent : intents) {
+ Object id = intent.get("id");
+ if (!(id instanceof String) || !IOSAppIntentsBuilder.publishesUserActivity(intent)) {
+ // Only the ones that reach NSUserActivityTypes. An intent that donates nothing
+ // shares no namespace with continuity and is none of this check's business.
+ continue;
+ }
+ if (continuityType.equals(id)) {
+ throw new BuildException("An App Intent declares the id '" + id + "', which is "
+ + "the activity type this build publishes for continuity. Both are "
+ + "advertised through NSUserActivityTypes and the native side has "
+ + "nothing left to tell them apart, so one handler would claim the "
+ + "other's activity. Give the intent an id of its own.");
+ }
+ }
+ }
+
+ /// Refuses a fragment that declares NSUserActivityTypes twice at the root.
+ ///
+ /// The trap UIApplicationSceneManifest is already refused for, and it resolves the same way: a
+ /// property list takes the LAST of a duplicated key, while every lookup here answers with the
+ /// first. Merging into the first would leave the second in force on the device -- a build that
+ /// succeeds with the activity types sitting in an array iOS never reads, so Handoff and
+ /// Spotlight are silently not advertised and nothing says so until they do not work.
+ ///
+ /// There is no safe pick between them. Fragments composed by more than one injector are how
+ /// this arises, and merging into either one is a guess about which the parser will keep, so it
+ /// is reported rather than guessed at.
+ ///
+ /// Comment-aware, because plistMemberRange walks live elements only: a declaration the project
+ /// kept COMMENTED OUT above its real one is not a second declaration, and refusing that would
+ /// break the very projects the live-element handling was added for.
+ ///
+ /// #### Parameters
+ ///
+ /// - `inject`: the plist fragment the application supplied
+ ///
+ /// #### Throws
+ ///
+ /// - `BuildException`: when the key is declared more than once at the root
+ static void requireSingleUserActivityTypes(String inject) throws BuildException {
+ if (!plistMemberDuplicated(inject, 0, inject.length(), "NSUserActivityTypes")) {
+ return;
+ }
+ throw new BuildException("ios.plistInject declares NSUserActivityTypes twice. A property "
+ + "list takes the last of a duplicated key, so this build cannot tell which array "
+ + "the device will read -- and the activity types would go into the other one. "
+ + "Compose them into one array.");
+ }
+
+ /// The same merge, adding the continuity activity type alongside the intent ids.
+ ///
+ /// #### Parameters
+ ///
+ /// - `inject`: the plist fragment the application supplied
+ /// - `intents`: the app's intent declarations, possibly empty
+ /// - `continuityType`: this app's continuity activity type, or null when it uses none
+ static String mergeUserActivityTypes(String inject, List> intents,
+ String continuityType) throws BuildException {
// The same structural reading the rest of the plist parsing uses: this walks a
// fragment the application supplied, so "" and " " are shapes it
// has to accept. Found by enumerating every literal closing tag left in this
// file rather than waiting for the next one to be reported.
- int key = plistKeyIndex(inject, "NSUserActivityTypes");
- int open = key < 0 ? -1 : plistElementIndex(inject, "array", key);
- int close = open < 0 ? -1 : plistCloseElementIndex(inject, "array", open);
+ // The LIVE key, not the first one that matches. A project that kept an old declaration
+ // commented out above its real one had the ids merged into the comment: the branch above
+ // correctly saw a live key, and this then found the dead one first. The plist that
+ // shipped had no continuity type in the array iOS actually reads, so Handoff was never
+ // advertised and nothing anywhere said so.
+ int key = firstLiveRootIndex(inject, "NSUserActivityTypes");
+ // The key's OWN value, not the next array anywhere after it. An unbounded search reached
+ // past a NSUserActivityTypes whose value was not an array and inserted the ids into some
+ // later key's array -- corrupting a property this method was never asked about, while the
+ // documented behaviour for "no array here" is to return the fragment untouched.
+ int open = immediateValueIndex(inject, key);
+ if (open < 0 || !inject.startsWith("com.example.app.continuity -->", so the builder added nothing and
+ // the array iOS actually reads never carried the type -- Handoff silently not advertised,
+ // which is the same failure the commented-out KEY case already had one level up.
StringBuilder add = new StringBuilder();
for (Map intent : intents) {
Object id = intent.get("id");
@@ -11637,10 +12197,14 @@ static String mergeUserActivityTypes(String inject, List> in
// business in the app's own array either. See publishesUserActivity.
if (id instanceof String
&& IOSAppIntentsBuilder.publishesUserActivity(intent)
- && !existing.contains("" + (String) id + " ")) {
+ && !listsLiveString(existing, (String) id)) {
add.append("").append((String) id).append(" ");
}
}
+ if (continuityType != null && continuityType.length() > 0
+ && !listsLiveString(existing, continuityType)) {
+ add.append("").append(continuityType).append(" ");
+ }
if (add.length() == 0) {
return inject;
}
@@ -12566,16 +13130,59 @@ static String topLevelPlistString(String plist, String key) {
return null;
}
String value = plist.substring(range[0], range[1]).trim();
- if (value.startsWith("" opens a string exactly as ""
+ // does, and a comment or a processing instruction may sit between the key and its value
+ // -- all three are ordinary plist, and a literal startsWith() answered "not a string" to
+ // every one of them. withContinuityActivityType() then refused the declaration as
+ // non-string and FAILED A CORRECT BUILD, which is worse than the silent mismatch this
+ // method's other readers would have got.
+ //
+ // The same structural rule the container tags a few hundred lines up already follow:
+ // plistElementIndex() was made to see "" for this reason, and this was the last
+ // literal check left beside it.
+ String name = nextElementName(value, 0);
+ if ("true".equals(name)) {
return "true";
}
- if (value.startsWith("") || !value.endsWith(" ")) {
+ if (!"string".equals(name)) {
+ return null;
+ }
+ int open = plistElementIndex(value, "string", 0);
+ int contentStart = open < 0 ? -1 : plistOpenTagEnd(value, open);
+ int contentEnd = contentStart < 0
+ ? -1 : plistCloseElementIndex(value, "string", contentStart);
+ if (contentEnd < 0) {
return null;
}
- return value.substring("".length(), value.length() - " ".length()).trim();
+ value = "" + value.substring(contentStart, contentEnd) + " ";
+ // The element's CONTENT through the shared resolver, not a slice of its serialization.
+ // A plist author may spell a value with character references -- "com.example.app" --
+ // or wrap it in CDATA, or put a comment inside the element, and Foundation reads all
+ // three as the same string. Handing back the raw text made every caller compare the
+ // serialization instead: withContinuityActivityType saw "com.example.app.continuity"
+ // where the build publishes "com.example.app.continuity", called that a conflicting
+ // declaration, and failed a build that was correct.
+ //
+ // The key side of this method already resolves the same way -- plistMemberRange compares
+ // key names through this helper -- so the two halves were answering one question two
+ // different ways.
+ //
+ // TRIMMED, and it stays trimmed. A review asked for the exact value on the grounds that
+ // Foundation preserves padding inside a , so " com.example.app "
+ // would compare equal to the unpadded type here and leave a fragment whose delegate reads
+ // the padded one. True, and not worth what closing it costs: this method is the shared
+ // reader for every top-level plist string in this builder -- entitlement values, bundle
+ // ids, the container identifier -- and making it significant-whitespace would change what
+ // all of them accept for the sake of an activity type somebody wrote with spaces around
+ // it. The failure it leaves is a Handoff that does not work in an app whose plist says
+ // something the author did not mean, which is diagnosable; the failure it would introduce
+ // is spread across every other key this reads.
+ String content = WatchNativeBuilder.plistStringContentExact(
+ value.substring("".length(), value.length() - " ".length()));
+ return content == null ? null : content.trim();
}
/// Whether the fragment declares the key as a member of ITS OWN level.
@@ -14178,21 +14785,57 @@ public boolean accept(File file, String string) {
// Emitted whenever the app declares intents, including the appIntents=false opt-out:
// donation still runs there, and iOS only offers an activity whose type is declared
// here, so omitting it would make the opt-out donate into a void.
- if (declaresAppIntents || appIntentsSuppressed) {
+ //
+ // Continuity contributes to the SAME key. iOS only continues an activity whose type the
+ // app declared here, so an app that references com.codename1.continuity and never lands
+ // in this branch publishes activities no other device is ever offered -- and the symptom
+ // is the feature appearing to do nothing at all, on both devices, with nothing logged.
+ String continuityActivityType = usesContinuity
+ ? request.getPackageName() + ".continuity" : null;
+ if (declaresAppIntents || appIntentsSuppressed || usesContinuity) {
// Each key is decided on its own. Treating any existing NSUserActivityTypes as
// complete configuration meant an app that already declared one Handoff activity
// through ios.plistInject silently lost every intent id -- and lost
// CoreSpotlightContinuation too, which is a different key entirely, so a Spotlight
// result could not continue into the app either.
- if (!inject.contains("NSUserActivityTypes")) {
- inject += userActivityTypesKey(intentsManifest);
+ // LIVE elements only, and the array normalized first. A plain contains() answered
+ // yes for a declaration the project had COMMENTED OUT -- the builder then stood
+ // aside, merged the ids into the comment, and shipped an app with no live activity
+ // type at all, which is Handoff and Spotlight silently doing nothing on a device.
+ // The same question is asked of UIBackgroundModes a few hundred lines up, and for
+ // the same reason.
+ // The fragment itself, NOT plistWithoutComments(inject). firstLiveRootIndex already
+ // skips a key that is commented out, and pre-stripping introduced a failure of its
+ // own: a valid CDATA value containing the text "" looked like an
+ // unterminated comment, so everything after it was truncated and a live root key
+ // beyond it went missing -- and this branch then appended a SECOND one.
+ requireSingleUserActivityTypes(inject);
+ requireNoIntentClaimsTheContinuityType(intentsManifest, continuityActivityType);
+ if (firstLiveRootIndex(inject, "NSUserActivityTypes") < 0) {
+ inject += userActivityTypesKey(intentsManifest, continuityActivityType);
} else {
// Merge into the array the application supplied rather than replacing it: its
// own activity types have to keep working. Appended just before the closing
// of that key, and only ids it does not already list.
- inject = mergeUserActivityTypes(inject, intentsManifest);
- }
- }
+ //
+ // Expanded first: " " is a valid empty array and the merge looks for a
+ // literal open/close pair, so an app that declared the key that way took the
+ // merge branch and had every id dropped on the floor.
+ inject = mergeUserActivityTypes(expandEmptyUserActivityArray(inject),
+ intentsManifest, continuityActivityType);
+ }
+ }
+
+ // The resolved type, written where the NATIVE side can read it. The delegate decides
+ // whether an arriving NSUserActivity is this framework's before any Java is running, and
+ // deriving that from [[NSBundle mainBundle] bundleIdentifier] is WRONG on the Mac slice:
+ // DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes that id ".maccatalyst", so
+ // the derived type would be ".maccatalyst.continuity" while the type this build
+ // declares above and the app publishes is ".continuity". Handoff would be dead
+ // on Catalyst, which is the Mac-to-iPhone case the feature exists for. One value, decided
+ // here, read by both slices: the Mac plist is generated from this finished one, so it
+ // carries the key unchanged.
+ inject = withContinuityActivityType(inject, continuityActivityType);
// CoreSpotlightContinuation is about Spotlight, not about App Intents, and gating it on
// a declaration made an entire supported configuration silently useless: an app that
// only calls Intents.index() declares no intent at all -- parseIntentsManifest treats a
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java
index d5cf21bf503..7d8d5323841 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java
@@ -305,6 +305,15 @@ void writeEntitlements(BuildRequest request, File appSrcDir) throws IOException
}
}
+ /// Escapes a value going into the entitlements plist.
+ ///
+ /// A container identifier is normally plain, but it is project-supplied -- an app sharing a
+ /// store with a sibling names that sibling -- and an unescaped "&" turns the whole plist into
+ /// something codesign refuses to parse, which reads as a signing failure rather than a typo.
+ private static String escapeEntitlementValue(String value) {
+ return value.replace("&", "&").replace("<", "<").replace(">", ">");
+ }
+
private void writeEntitlementsFile(BuildRequest request, File appSrcDir,
String baseName, String channel) throws IOException {
boolean sandbox = parseEntitlementBool(request,
@@ -385,6 +394,53 @@ private void writeEntitlementsFile(BuildRequest request, File appSrcDir,
sb.append(" com.apple.security.personal-information.calendars \n \n");
}
}
+ // The Catalyst archive is signed with THIS plist, and it is assembled from the
+ // macNative.entitlements.* namespace alone -- so an entitlement the iOS side generated
+ // reached the iOS slice and silently missed the Mac one. NSUbiquitousKeyValueStore then
+ // has no container in the Mac slice of the very build that switched the shared code on,
+ // and SyncedStore fails at runtime on a Mac with nothing said at build time.
+ //
+ // Read from the value the iOS side already resolved rather than through a hint of its own.
+ // There is one correct container per app, and a second place to configure it is a second
+ // place for the two slices to disagree.
+ //
+ // The namespaced argument ALONE, and the BuildDaemon twin deliberately resolves more. A
+ // review asked for the raw ios.entitlementsInject fragment to be consulted here too, on
+ // the grounds that a project naming its container that way would sign the two slices for
+ // different stores. That is true THERE and false here: this builder never reads that hint
+ // -- buildNamespacedEntitlements merges it only in the daemon -- so locally the fragment
+ // reaches no plist at all and both slices use exactly this value. Consulting it here would
+ // be a check over a value this builder never sees, which is the same asymmetry the VPN
+ // entitlement above already documents. A twin diff showing it is reading the right answer.
+ String ubiquityKvStore = request.getArg(
+ "ios.entitlements.com.apple.developer.ubiquity-kvstore-identifier", null);
+ if (ubiquityKvStore != null && ubiquityKvStore.trim().length() > 0) {
+ // MATERIALIZED, not copied. $(CFBundleIdentifier) is target-relative and this is not
+ // the iOS target: DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the Catalyst
+ // bundle id ".maccatalyst" -- the same derivation the provisioning-profile
+ // block above already relies on -- so copying the expression verbatim signed this
+ // slice for TEAM..maccatalyst while the iOS slice used TEAM.. Two
+ // containers, neither able to see the other's writes, which is precisely the failure
+ // this entry was added to prevent.
+ //
+ // $(TeamIdentifierPrefix) is left alone: it is the same team in both targets.
+ String container = ubiquityKvStore.trim();
+ String iosBundleId = request.getPackageName();
+ if (iosBundleId != null && iosBundleId.length() > 0) {
+ // Through replaceBuildSetting, which knows BOTH spellings Xcode accepts. Listing
+ // "$(NAME)" by hand here meant a project writing "${CFBundleIdentifier}" -- the
+ // same reference, and equally valid -- left it unresolved, so the iOS entitlement
+ // expanded it against the iOS bundle id while this one expanded it against the
+ // derived Catalyst id and the two slices synchronized against different stores.
+ container = IPhoneBuilder.replaceBuildSetting(
+ container, "CFBundleIdentifier", iosBundleId);
+ container = IPhoneBuilder.replaceBuildSetting(
+ container, "PRODUCT_BUNDLE_IDENTIFIER", iosBundleId);
+ }
+ sb.append(" com.apple.developer.ubiquity-kvstore-identifier \n ")
+ .append(escapeEntitlementValue(container))
+ .append(" \n");
+ }
if (extra != null && extra.trim().length() > 0) {
sb.append(extra);
if (!extra.endsWith("\n")) {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java
index 357e2a82c57..918b93786da 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/CN1BuildMojo.java
@@ -403,6 +403,7 @@ private void applyIOSProvisioningPreflight(Properties mergedSettings) throws Moj
report(IOSProvisioningPreflight.checkAppExtensions(mergedSettings, release,
project.getBasedir()));
report(IOSProvisioningPreflight.checkGeneratedExtensions(mergedSettings, release));
+ report(IOSProvisioningPreflight.checkContinuitySync(mergedSettings, release));
}
private void report(List problems) throws MojoFailureException {
diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
index 18ef87974be..97dc9dd9648 100644
--- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
+++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/IOSProvisioningPreflight.java
@@ -101,6 +101,46 @@ static class Profile {
* not be parsed at all produces no Profile, which is where "cannot tell" still lives.
*/
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;
+
+ /**
+ * The container that entitlement names, exactly as the profile spells it, or null.
+ *
+ * Kept rather than reduced to the boolean above, because the boolean is what made the
+ * check unable to answer which container was granted -- a limitation the code then
+ * described as inherent when it was only self-inflicted. Codesigning rejects an app
+ * entitlement the profile's value does not cover, so a profile granting one container
+ * and a project naming another is a build that fails at signing.
+ */
+ String ubiquityKeyValueStoreValue;
+ }
+
+ /**
+ * Whether a container name is something two sides can be compared on.
+ *
+ * An Xcode variable expands at build time and a wildcard covers a set, so neither is a
+ * value this can hold against another one. Only a plain literal is.
+ *
+ * Any {@code $} at all, not the {@code $(} spelling alone. Xcode accepts
+ * {@code ${CFBundleIdentifier}} equally, this project's own Mac entitlement test writes the
+ * two forms in a single value -- {@code $(TeamIdentifierPrefix)${CFBundleIdentifier}} -- and
+ * replaceBuildSetting() substitutes both, with a comment recording that handling one and not
+ * the other was already a bug once. Missing a spelling here does not fail to warn, it warns
+ * WRONGLY: the profile holds the expanded identifier, so an override Xcode would expand
+ * correctly gets reported as a signing failure that will not happen. A container identifier is
+ * reverse-DNS and has no business containing a dollar sign, so treating every one of them as
+ * "not comparable" costs nothing and cannot invent a third spelling to miss.
+ */
+ private static boolean isLiteralContainer(String container) {
+ return container != null && !container.isEmpty()
+ && container.indexOf('$') < 0 && container.indexOf('*') < 0;
}
/** A problem found before the build was sent: {@code message} is written for the user. */
@@ -222,6 +262,105 @@ 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.
+ *
+ * Asked only of a project that set {@code ios.continuity.sync=true}, which is how a
+ * project says it wants the store without this check having to read bytecode.
+ *
+ * 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 the
+ * project did not declare the synced store or nothing readable says either way
+ */
+ static List checkContinuitySync(Properties settings, boolean release) {
+ List problems = new ArrayList();
+ if (settings == null) {
+ return problems;
+ }
+ // Keyed on the SYNC declaration alone. An earlier version keyed on a separate
+ // "continuity is in use" hint and warned the wrong projects: the builder asks for the
+ // entitlement only when it sees com.codename1.continuity.sync in the bytecode, so a
+ // project using continuity WITHOUT the synced store was told its profile could not sign
+ // an entitlement its build was never going to request. That hint had no other reader and
+ // is gone; this is the only declaration the question needs.
+ //
+ // An explicit true rather than a default, because this check has to be read as "the
+ // project says it wants a synced store". Absent means "the bytecode decides", which is
+ // exactly the thing nothing here can read; false means the entitlement is dropped. Only
+ // an explicit yes is a claim this can act on.
+ if (!"true".equals(trimmed(settings.getProperty(
+ "codename1.arg.ios.continuity.sync")))) {
+ return problems;
+ }
+ String override = trimmed(settings.getProperty("codename1.arg.ios.entitlements.com.apple"
+ + ".developer.ubiquity-kvstore-identifier"));
+ 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) {
+ // Granted -- and now WHICH container, when both sides say so literally.
+ //
+ // Only then. The value the build requests when the project names none is
+ // "$(TeamIdentifierPrefix)$(CFBundleIdentifier)", two Xcode variables this has no
+ // business expanding, and a profile may grant a wildcard. Comparing either of those
+ // would produce warnings on configurations that sign perfectly well, which is worse
+ // than staying quiet: a preflight that cries wolf is one people stop reading.
+ //
+ // Two literals that differ is the case that is certain, and it is the ordinary way to
+ // get this wrong -- an app sharing a sibling's store, which is exactly when a project
+ // names a container by hand.
+ if (isLiteralContainer(override) && isLiteralContainer(
+ appProfile.ubiquityKeyValueStoreValue)
+ && !override.equals(appProfile.ubiquityKeyValueStoreValue)) {
+ problems.add(new Problem("This project asks for the iCloud key-value store "
+ + "container \"" + override + "\", and the provisioning profile \""
+ + appProfile.name + "\" grants \""
+ + appProfile.ubiquityKeyValueStoreValue + "\".\n"
+ + "Codesigning rejects an app entitlement the profile does not cover, so "
+ + "this build fails when it is signed rather than when it is sent.\n"
+ + "Either point ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier at the container the profile grants, or "
+ + "regenerate the profile from an App ID whose iCloud capability includes "
+ + "the one you want.", false));
+ }
+ return problems;
+ }
+ // Not granted AT ALL, and an explicit container does not rescue that: the builder puts the
+ // entitlement into the app either way and codesigning rejects it. Returning early on the
+ // override, as this did, suppressed the one answer the preflight can give definitively --
+ // the unanswerable question is which container, and that is not the question here.
+ String named = override == null || override.isEmpty() ? ""
+ : "\nThe project names its own container (" + override + "). That does not change "
+ + "this: the profile grants no key-value store at all, so there is no "
+ + "container for it to share.";
+ 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." + named, false));
+ return problems;
+ }
+
/**
* Whether every app extension this build embeds can actually be signed.
*
@@ -1005,6 +1144,15 @@ 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;
+ if (ubiquity != null && "string".equals(ubiquity.getTagName())) {
+ String granted = ubiquity.getTextContent().trim();
+ profile.ubiquityKeyValueStoreValue = granted.isEmpty() ? null : granted;
+ }
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..a7d013bcc28
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java
@@ -0,0 +1,883 @@
+/*
+ * 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;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * {@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;
+ }
+
+ // ------------------------------------------------------------------
+ // The type the native side reads
+ // ------------------------------------------------------------------
+
+ @Test
+ void theResolvedActivityTypeIsDeclaredForTheNativeSide() throws BuildException {
+ String out = IPhoneBuilder.withContinuityActivityType("", CONTINUITY_TYPE);
+
+ assertTrue(out.contains("CN1ContinuityActivityType "), out);
+ assertTrue(out.contains("" + CONTINUITY_TYPE + " "), out);
+ assertEquals(1, occurrences(out, "CN1ContinuityActivityType "),
+ "the key must appear exactly once: " + out);
+ }
+
+ @Test
+ void anAppThatDoesNotUseContinuityGetsNoSuchKey() throws BuildException {
+ String out = IPhoneBuilder.withContinuityActivityType("Other x ",
+ null);
+
+ assertFalse(out.contains("CN1ContinuityActivityType"), out);
+ }
+
+ /**
+ * The delegate compares the arriving activity type against this key. Deriving it natively from
+ * the bundle identifier instead looks equivalent and is wrong on the Mac slice, where
+ * DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER makes the id "<package>.maccatalyst" --
+ * so the derived type would be "<package>.maccatalyst.continuity" while every device
+ * publishes "<package>.continuity", and Handoff would be silently dead on Catalyst.
+ * The value written here is the package's, not the bundle's.
+ */
+ @Test
+ void theDeclaredTypeIsThePackagesAndCarriesNoCatalystSuffix() throws BuildException {
+ String out = IPhoneBuilder.withContinuityActivityType("", CONTINUITY_TYPE);
+
+ assertTrue(out.contains("com.example.app.continuity "), out);
+ assertFalse(out.contains("maccatalyst"), out);
+ }
+
+ @Test
+ void anApplicationsOwnMatchingDeclarationIsLeftAlone() throws BuildException {
+ String existing = "CN1ContinuityActivityType " + CONTINUITY_TYPE
+ + " ";
+
+ String out = IPhoneBuilder.withContinuityActivityType(existing, CONTINUITY_TYPE);
+
+ assertEquals(existing, out, "a matching declaration must not be duplicated");
+ assertEquals(1, occurrences(out, "CN1ContinuityActivityType "), out);
+ }
+
+ /**
+ * A stale injected value is refused rather than left standing. It is not a build hint: the
+ * type is what NSUserActivityTypes declares and what every device publishes, so a fragment
+ * naming a different one has the delegate turning away the application's own continuations
+ * while iOS keeps offering them -- nothing fails and nothing is logged.
+ */
+ @Test
+ void aDisagreeingInjectedTypeIsRefused() {
+ String existing = "CN1ContinuityActivityType com.other.app.continuity"
+ + " ";
+
+ try {
+ IPhoneBuilder.withContinuityActivityType(existing, CONTINUITY_TYPE);
+ fail("a fragment naming a different activity type must not be accepted");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("CN1ContinuityActivityType"),
+ expected.getMessage());
+ assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage());
+ }
+ }
+
+ /**
+ * A declaration of the wrong plist TYPE is refused rather than accepted. topLevelPlistString
+ * answers null for an array or a dict, which used to mean the value was left alone (something
+ * was declared) and ours was not added (the key was present) -- so the build succeeded and
+ * the delegate found a value that is not an NSString, treated it as absent, and let every
+ * continuation bypass the handler on a build that looked configured.
+ */
+ @Test
+ void aNonStringDeclarationIsRefused() {
+ String arrayValued = "CN1ContinuityActivityType "
+ + "com.example.app.continuity ";
+
+ try {
+ IPhoneBuilder.withContinuityActivityType(arrayValued, CONTINUITY_TYPE);
+ fail("a non-string CN1ContinuityActivityType must not be accepted");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("non-string"), expected.getMessage());
+ }
+ }
+
+ /**
+ * A declaration spelled with character references is the SAME declaration.
+ *
+ * Foundation resolves {@code com.example.app.continuity } to
+ * {@code com.example.app.continuity}, so a project that spells its injected type that way has
+ * declared exactly what this build publishes. topLevelPlistString sliced the raw XML between
+ * the tags and handed back the undecoded text, so the equality check called it a CONFLICTING
+ * declaration and failed a build that was correct.
+ *
+ * The key half of the same method already resolved through the shared helper -- that is
+ * what makes the fragment findable at all -- so the two halves were answering one question
+ * two different ways.
+ */
+ @Test
+ void aDeclarationSpelledWithCharacterReferencesAgrees() throws BuildException {
+ String inject = "CN1ContinuityActivityType "
+ + "com.example.app.continuity ";
+
+ String out = IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE);
+
+ assertEquals(inject, out,
+ "an agreeing declaration spelled with a character reference was treated as a "
+ + "conflict, so a correct build was refused");
+ }
+
+ /**
+ * The same resolution must not blunt the conflict check itself.
+ *
+ * Decoding is only correct if a genuinely different type still fails: a declaration naming
+ * another app's type has the delegate rejecting this application's own continuations while
+ * iOS goes on offering them.
+ */
+ @Test
+ void aDifferentTypeSpelledWithCharacterReferencesStillConflicts() {
+ String inject = "CN1ContinuityActivityType "
+ + "com.other.app.continuity ";
+
+ try {
+ IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE);
+ fail("a declaration naming a different type must still be refused");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("com.other.app.continuity"),
+ "the message should name the DECODED type the project declared: "
+ + expected.getMessage());
+ }
+ }
+
+ /**
+ * Two live root NSUserActivityTypes declarations are refused rather than guessed between.
+ *
+ * A property list takes the LAST of a duplicated key while every lookup here answers with
+ * the first, so merging into the first leaves the second in force on the device: a build that
+ * succeeds with the activity types sitting in an array iOS never reads, and Handoff silently
+ * not advertised. UIApplicationSceneManifest is already refused for exactly this.
+ */
+ @Test
+ void twoLiveActivityTypesDeclarationsAreRefused() {
+ String inject = "NSUserActivityTypes "
+ + "com.example.app.legacy "
+ + "NSUserActivityTypes "
+ + "com.example.app.replacement ";
+
+ try {
+ IPhoneBuilder.requireSingleUserActivityTypes(inject);
+ fail("a duplicated NSUserActivityTypes must not be silently merged into one of them");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("twice"), expected.getMessage());
+ }
+ }
+
+ /**
+ * A declaration the project COMMENTED OUT is not a second declaration.
+ *
+ * The live-element handling exists for exactly this shape -- a project that kept its old
+ * declaration above the real one -- so a duplicate check that counted the comment would refuse
+ * the projects that handling was added for.
+ */
+ @Test
+ void aCommentedOutActivityTypesDeclarationIsNotADuplicate() throws BuildException {
+ String inject = ""
+ + "NSUserActivityTypes "
+ + "com.example.app.replacement ";
+
+ IPhoneBuilder.requireSingleUserActivityTypes(inject);
+ }
+
+ /**
+ * A declaration spelled with ordinary plist formatting is still a string declaration.
+ *
+ * {@code } opens a string exactly as {@code } does, and a comment may sit
+ * between the key and its value. Both are ordinary plist; a literal startsWith() answered "not
+ * a string" to each, and withContinuityActivityType() then refused the declaration as
+ * non-string and failed a build that was correct -- worse than the silent mismatch the same
+ * reader would produce elsewhere. The container tags a few hundred lines up were made
+ * structural for this exact reason; this was the last literal check beside them.
+ */
+ @Test
+ void anOddlySpelledStringDeclarationIsStillAString() throws BuildException {
+ String spaced = "CN1ContinuityActivityType "
+ + CONTINUITY_TYPE + " ";
+ assertEquals(spaced, IPhoneBuilder.withContinuityActivityType(spaced, CONTINUITY_TYPE),
+ "a declaration written as was refused as a non-string value");
+
+ String commented = "CN1ContinuityActivityType "
+ + CONTINUITY_TYPE + " ";
+ assertEquals(commented,
+ IPhoneBuilder.withContinuityActivityType(commented, CONTINUITY_TYPE),
+ "a comment between the key and its value made the value unreadable");
+ }
+
+ /**
+ * Two live root CN1ContinuityActivityType declarations are refused rather than half-read.
+ *
+ * The same trap NSUserActivityTypes and UIApplicationSceneManifest are already refused for,
+ * on the neighbouring key: a property list takes the LAST of a duplicated key while every
+ * lookup here answers with the first, so an agreeing first declaration is left alone while the
+ * delegate reads a different second one. The generated array then advertises one type and the
+ * native side accepts another, which is Handoff silently dead.
+ */
+ @Test
+ void twoLiveContinuityTypeDeclarationsAreRefused() {
+ String inject = "CN1ContinuityActivityType " + CONTINUITY_TYPE
+ + " CN1ContinuityActivityType "
+ + "com.other.app.continuity ";
+
+ try {
+ IPhoneBuilder.withContinuityActivityType(inject, CONTINUITY_TYPE);
+ fail("a duplicated CN1ContinuityActivityType must not be read from the first one");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("twice"), expected.getMessage());
+ }
+ }
+
+ /**
+ * An App Intent may not claim the continuity activity type as its own id.
+ *
+ * An intent's id is published as its NSUserActivity activityType verbatim, and the
+ * continuity type goes into the same array. Declare both and the native delegate has nothing
+ * left to tell them apart, so whichever handler looks first claims an activity meant for the
+ * other -- Handoff resuming into an intent's screen, or an intent invocation restoring a route
+ * stack, with nothing logged either way.
+ */
+ @Test
+ void anIntentMayNotClaimTheContinuityType() {
+ List> colliding = intents(CONTINUITY_TYPE);
+
+ try {
+ IPhoneBuilder.requireNoIntentClaimsTheContinuityType(colliding, CONTINUITY_TYPE);
+ fail("an intent publishing the continuity activity type must not be accepted");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage());
+ }
+ }
+
+ /**
+ * And an ordinary intent beside continuity is untouched, or the guard would refuse every app
+ * that uses both features -- which is the ordinary case the key exists to serve.
+ */
+ @Test
+ void anOrdinaryIntentBesideContinuityIsFine() throws BuildException {
+ IPhoneBuilder.requireNoIntentClaimsTheContinuityType(
+ intents("logWorkout", "com.example.app.sendMessage"), CONTINUITY_TYPE);
+ }
+
+ /**
+ * A NSUserActivityTypes whose value is not an array is refused once a continuity type depends
+ * on it.
+ *
+ * The caller has already seen the key, so it writes no array of its own, and the merge's
+ * documented answer for "no array here" is to return the fragment untouched -- which is right
+ * for the intents-only merge, because a SECOND NSUserActivityTypes key is a plist iOS reads
+ * unpredictably. Together they mean the continuity type reaches no array at all: the build
+ * succeeds, CN1ContinuityActivityType is present, and Handoff is never advertised.
+ */
+ @Test
+ void aNonArrayActivityTypesDeclarationIsRefusedWhenContinuityNeedsIt() throws BuildException {
+ String inject = "NSUserActivityTypes com.example.app.other ";
+
+ try {
+ IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+ fail("a NSUserActivityTypes that is not an array must not be silently accepted");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("NSUserActivityTypes"),
+ expected.getMessage());
+ assertTrue(expected.getMessage().contains(CONTINUITY_TYPE), expected.getMessage());
+ }
+ }
+
+ /**
+ * An array that is opened and never closed is refused for the same reason: the merge cannot
+ * find where to insert, so it returns the fragment untouched and the type goes nowhere.
+ */
+ @Test
+ void anUnclosedActivityTypesArrayIsRefusedWhenContinuityNeedsIt() throws BuildException {
+ String inject = "NSUserActivityTypes a ";
+
+ try {
+ IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+ fail("an unterminated NSUserActivityTypes array must not be silently accepted");
+ } catch (BuildException expected) {
+ assertTrue(expected.getMessage().contains("never closed"), expected.getMessage());
+ }
+ }
+
+ /**
+ * The refusal is scoped to builds that need the array. An intents-only project with the same
+ * malformed declaration keeps the behaviour it has today: its plist is wrong either way -- iOS
+ * requires an array here -- and failing those builds is not this feature's change to make.
+ *
+ * This is the half that keeps the two tests above honest. A refusal that fired
+ * unconditionally would satisfy both of them and break every existing project.
+ */
+ @Test
+ void theSameDeclarationIsLeftAloneWhenNoContinuityTypeNeedsIt() throws BuildException {
+ String inject = "NSUserActivityTypes com.example.app.other ";
+
+ assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null));
+ assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents()));
+ }
+
+ // ------------------------------------------------------------------
+ // 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(), ""));
+ }
+
+ // ------------------------------------------------------------------
+ // Only the root dictionary's own declaration counts
+ // ------------------------------------------------------------------
+
+ /**
+ * Why the detection branch reads the fragment itself instead of stripping comments first.
+ * plistWithoutComments is not CDATA-aware: a valid CDATA value carrying the text "<!--"
+ * and no "-->" looks like an unterminated comment to it, so everything after is truncated
+ * and a live root key beyond it disappears -- and the branch then appends a second one.
+ */
+ @Test
+ void strippingCommentsFirstWouldHideALiveKeyAfterCdata() {
+ String plist = "Note "
+ + "NSUserActivityTypes ";
+
+ assertTrue(IPhoneBuilder.firstLiveRootIndex(plist, "NSUserActivityTypes") > 0, plist);
+ assertEquals(-1, IPhoneBuilder.firstLiveRootIndex(
+ IPhoneBuilder.plistWithoutComments(plist), "NSUserActivityTypes"), plist);
+ }
+
+ /**
+ * A commented-out entry is not a declaration. Treating one as already-present added nothing,
+ * so the array iOS actually reads never carried the continuity type and Handoff was silently
+ * not advertised -- the same failure the commented-out KEY case has one level up.
+ */
+ @Test
+ void aCommentedOutEntryDoesNotSuppressTheType() throws BuildException {
+ String inject = "NSUserActivityTypes "
+ + ""
+ + "com.example.app.other ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ // Twice in the TEXT -- once dead in the comment, once live -- which is the point.
+ assertEquals(2, occurrences(merged, CONTINUITY_TYPE), merged);
+ }
+
+ /**
+ * A processing instruction between a key and its value is markup a plist parser steps over.
+ * Stopping on it made immediateValueIndex answer with the "<?", so both the expansion and
+ * the merge decided the value was not an array and dropped every activity type.
+ */
+ @Test
+ void aProcessingInstructionBetweenKeyAndArrayIsSteppedOver() throws BuildException {
+ String inject = "NSUserActivityTypes ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(
+ IPhoneBuilder.expandEmptyUserActivityArray(inject), noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ }
+
+ /**
+ * A CDATA section is character data, not markup. "<dict>" written inside one is text an
+ * application chose to store, and counting it as structure classified a following ROOT
+ * NSUserActivityTypes as nested -- so the builder appended a second one and shipped a plist
+ * carrying the key twice, which iOS reads unpredictably.
+ */
+ @Test
+ void markupInsideCdataIsNotStructure() {
+ String plist = "Note ]]> "
+ + "NSUserActivityTypes ";
+
+ assertTrue(IPhoneBuilder.firstLiveRootIndex(plist, "NSUserActivityTypes") > 0, plist);
+ }
+
+ /**
+ * The reverse: a dict that really does open, with a CDATA section inside it, still nests.
+ * A fix that simply ignored every "<dict>" would pass the test above and lose this.
+ */
+ @Test
+ void aRealDictStillNestsWhenItContainsCdata() {
+ String plist = "MyFeature "
+ + "Note "
+ + "NSUserActivityTypes ";
+
+ assertEquals(-1, IPhoneBuilder.firstLiveRootIndex(plist, "NSUserActivityTypes"), plist);
+ }
+
+ /**
+ * A valid CDATA value may contain the text "<!--" and no "-->". Stripping comments
+ * before the lookup read that as an unterminated comment and truncated the fragment, so a
+ * live root key after it went missing and a second one was appended beside it.
+ */
+ @Test
+ void aCommentMarkerInsideCdataDoesNotHideALaterKey() {
+ String plist = "Note "
+ + "NSUserActivityTypes ";
+
+ assertTrue(IPhoneBuilder.firstLiveRootIndex(plist, "NSUserActivityTypes") > 0, plist);
+ }
+
+ /**
+ * iOS reads NSUserActivityTypes at the plist root and nowhere else. Treating one that an
+ * application-defined nested dictionary happens to own as the app's declaration merged the
+ * continuity type into a dictionary nobody reads it from, AND suppressed the root key that
+ * would have advertised Handoff -- so the feature was silently inert while an unrelated
+ * property was quietly rewritten.
+ */
+ @Test
+ void aNestedActivityTypesDeclarationIsNotTheAppsDeclaration() {
+ String nested = "MyFeature "
+ + "NSUserActivityTypes "
+ + "com.example.app.nested ";
+
+ assertEquals(-1, IPhoneBuilder.firstLiveRootIndex(nested, "NSUserActivityTypes"), nested);
+ }
+
+ /** The root declaration is still found when a nested one precedes it. */
+ @Test
+ void theRootDeclarationIsFoundPastANestedOne() {
+ String both = "MyFeature "
+ + "NSUserActivityTypes "
+ + "com.example.app.nested "
+ + "NSUserActivityTypes "
+ + "com.example.app.root ";
+
+ int at = IPhoneBuilder.firstLiveRootIndex(both, "NSUserActivityTypes");
+
+ assertTrue(at > both.indexOf(""), "resolved the nested key at " + at + ": " + both);
+ }
+
+ /** The merge follows the same rule, or it rewrites an array the detection branch ignored. */
+ @Test
+ void theMergeTargetsTheRootArrayNotANestedOne() throws BuildException {
+ String both = "MyFeature "
+ + "NSUserActivityTypes "
+ + "com.example.app.nested "
+ + "NSUserActivityTypes "
+ + "com.example.app.root ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(both, noIntents(), CONTINUITY_TYPE);
+
+ int nestedEnd = merged.indexOf("");
+ assertTrue(merged.indexOf(CONTINUITY_TYPE) > nestedEnd,
+ "the continuity type landed inside the nested dictionary: " + merged);
+ assertEquals(1, occurrences(merged, CONTINUITY_TYPE), merged);
+ assertTrue(merged.contains("com.example.app.nested "),
+ "the nested array was rewritten: " + merged);
+ }
+
+ /** A self-closing dict is one element and must not be read as opening a nesting level. */
+ @Test
+ void aSelfClosingDictDoesNotOpenANestingLevel() {
+ String plist = "Empty "
+ + "NSUserActivityTypes ";
+
+ assertTrue(IPhoneBuilder.firstLiveRootIndex(plist, "NSUserActivityTypes") > 0, plist);
+ }
+
+ // ------------------------------------------------------------------
+ // Merging into an array the application supplied
+ // ------------------------------------------------------------------
+
+ @Test
+ void continuityMergesIntoAnArrayTheApplicationDeclared() throws BuildException {
+ 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() throws BuildException {
+ 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() throws BuildException {
+ String inject = "NSUserActivityTypes "
+ + "" + CONTINUITY_TYPE + " ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ assertEquals(1, occurrences(merged, "" + CONTINUITY_TYPE + " "), merged);
+ }
+
+ /**
+ * Returning the fragment unchanged is still the answer when nothing depends on the array.
+ *
+ * This test used to pass a continuity type and assert the same thing, which is the
+ * behaviour that shipped the feature inert: the caller sees the key and writes no array, this
+ * writes nothing into the one that is there, and the type ends up in no array at all. Its
+ * real subject -- that a value which is not an array is never edited -- is unchanged and now
+ * asked without a continuity type; the refusal has tests of its own above.
+ */
+ @Test
+ void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() throws BuildException {
+ String inject = "NSUserActivityTypes not an array ";
+
+ assertEquals(inject, IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), null));
+ }
+
+ /**
+ * The parser has to accept the shapes a hand-written fragment really carries.
+ */
+ @Test
+ void aSpacedClosingTagIsStillMergedInto() throws BuildException {
+ 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);
+ }
+
+ // ------------------------------------------------------------------
+ // The shapes a hand-written ios.plistInject really carries
+ // ------------------------------------------------------------------
+
+ /**
+ * {@code } is the ordinary XML spelling of an empty array and a plist parser reads it
+ * as {@code }. The merge looks for the literal pair, so without expansion an
+ * app that declared the key that way took the merge branch and had every id dropped -- worse
+ * than a duplicate key, because nothing says so until Handoff does not work on a device.
+ */
+ @Test
+ void aSelfClosingArrayIsExpandedSoTheMergeCanSeeIt() throws BuildException {
+ String inject = "NSUserActivityTypes ";
+
+ String expanded = IPhoneBuilder.expandEmptyUserActivityArray(inject);
+ String merged = IPhoneBuilder.mergeUserActivityTypes(expanded, noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged);
+ }
+
+ @Test
+ void aSelfClosingArrayWithWhitespaceAndASpacedTagIsStillExpanded() throws BuildException {
+ String inject = "NSUserActivityTypes \n ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(
+ IPhoneBuilder.expandEmptyUserActivityArray(inject), intents("logWorkout"),
+ CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("logWorkout "), merged);
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ }
+
+ /** An array that is already a pair is left exactly as it was. */
+ @Test
+ void anOpenClosePairIsNotRewritten() {
+ String inject = "NSUserActivityTypes a ";
+
+ assertEquals(inject, IPhoneBuilder.expandEmptyUserActivityArray(inject));
+ }
+
+ /** Another key's empty array is none of this method's business. */
+ @Test
+ void anUnrelatedEmptyArrayIsNotRewritten() {
+ String inject = "NSUserActivityTypes a "
+ + "SomethingElse ";
+
+ String out = IPhoneBuilder.expandEmptyUserActivityArray(inject);
+
+ assertTrue(out.contains("SomethingElse "), out);
+ }
+
+ @Test
+ void aFragmentWithoutTheKeyIsLeftAlone() {
+ String inject = "SomethingElse ";
+
+ assertEquals(inject, IPhoneBuilder.expandEmptyUserActivityArray(inject));
+ }
+
+ /**
+ * The decision has to be made on LIVE elements. A commented-out declaration answered a plain
+ * contains() yes, so the builder stood aside, merged into the comment, and shipped an app
+ * with no live activity type at all.
+ */
+ @Test
+ void aCommentedOutDeclarationDoesNotCountAsSupplied() {
+ String inject = "";
+
+ assertTrue(IPhoneBuilder.plistKeyIndex(
+ IPhoneBuilder.plistWithoutComments(inject), "NSUserActivityTypes") < 0,
+ "a commented-out key must read as absent, which is what makes the builder "
+ + "emit a live one of its own");
+ }
+
+ /** A live declaration beside a commented-out one still reads as supplied. */
+ @Test
+ void aLiveDeclarationBesideACommentedOneCountsAsSupplied() {
+ String inject = ""
+ + "NSUserActivityTypes a ";
+
+ assertTrue(IPhoneBuilder.plistKeyIndex(
+ IPhoneBuilder.plistWithoutComments(inject), "NSUserActivityTypes") >= 0);
+ }
+
+ /**
+ * The case that survived the previous round: an old declaration kept commented out ABOVE the
+ * live one. The branch that calls the merge correctly saw a live key; the merge then found
+ * the dead one first and inserted into the comment, so the array iOS actually reads shipped
+ * without the continuity type and Handoff was never advertised.
+ */
+ @Test
+ void aCommentedDeclarationAboveALiveOneIsNotTheOneMergedInto() throws BuildException {
+ String inject = ""
+ + "NSUserActivityTypes "
+ + "com.example.app.live ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE);
+
+ int comment = merged.indexOf("-->");
+ int added = merged.indexOf("" + CONTINUITY_TYPE + " ");
+ assertTrue(added > comment,
+ "the continuity type landed inside the commented-out array: " + merged);
+ assertTrue(merged.contains("com.example.app.live "), merged);
+ }
+
+ /** The expander targets the live key too, for the same reason. */
+ @Test
+ void aCommentedSelfClosingArrayIsNotTheOneExpanded() {
+ String inject = ""
+ + "NSUserActivityTypes ";
+
+ String expanded = IPhoneBuilder.expandEmptyUserActivityArray(inject);
+
+ assertTrue(expanded.contains(""),
+ "the commented array was rewritten: " + expanded);
+ assertTrue(expanded.endsWith(" "), expanded);
+ }
+
+ @Test
+ void insideCommentRecognizesBothSides() {
+ String s = "aacc";
+ assertFalse(IPhoneBuilder.insideComment(s, 0));
+ assertTrue(IPhoneBuilder.insideComment(s, 6));
+ assertFalse(IPhoneBuilder.insideComment(s, 12));
+ }
+
+ /** An unterminated comment swallows the rest, which is what a parser does with it too. */
+ @Test
+ void anUnterminatedCommentSwallowsWhatFollows() {
+ String s = "aa ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(
+ IPhoneBuilder.expandEmptyUserActivityArray(inject), noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged);
+ }
+
+ /**
+ * When this key's value is not an array, the ids never reach a LATER key's array. An unbounded
+ * search reached past it and inserted them into the next array it found, corrupting a property
+ * this code was never asked about.
+ *
+ * Asked both ways, because the refusal must not be mistaken for the guarantee. Without a
+ * continuity type the fragment comes back untouched, which is what proves nothing was
+ * borrowed; with one the build is refused, and the refusal has to happen INSTEAD of the
+ * corruption rather than after it -- so the unrelated array is checked in the message-free
+ * path where it could actually have been edited.
+ */
+ @Test
+ void aNonArrayValueDoesNotBorrowALaterKeysArray() throws BuildException {
+ String inject = "NSUserActivityTypes not an array "
+ + "SomethingElse keep ";
+
+ assertEquals(inject,
+ IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), null),
+ "an unrelated array was edited");
+
+ try {
+ IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), CONTINUITY_TYPE);
+ fail("a continuity type with nowhere to go must not be accepted");
+ } catch (BuildException expected) {
+ assertFalse(expected.getMessage().contains("SomethingElse"), expected.getMessage());
+ }
+ }
+
+ @Test
+ void aNonArrayValueIsNotExpandedEither() {
+ String inject = "NSUserActivityTypes "
+ + "SomethingElse ";
+
+ assertEquals(inject, IPhoneBuilder.expandEmptyUserActivityArray(inject));
+ }
+
+ @Test
+ void immediateValueIndexStepsOverWhitespaceAndComments() {
+ String plist = "K ";
+ int at = IPhoneBuilder.immediateValueIndex(plist, 0);
+
+ assertTrue(at > 0, "no value found");
+ assertTrue(plist.startsWith("KNSUserActivityTypes ";
+
+ String merged = IPhoneBuilder.mergeUserActivityTypes(
+ IPhoneBuilder.expandEmptyUserActivityArray(inject), noIntents(), CONTINUITY_TYPE);
+
+ assertTrue(merged.contains("" + CONTINUITY_TYPE + " "), merged);
+ }
+
+ @Test
+ void nothingToAddLeavesTheFragmentAlone() throws BuildException {
+ 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/builders/MacNativeBuilderEntitlementsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java
index 3a32235b2f7..eeb9acc296c 100644
--- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/MacNativeBuilderEntitlementsTest.java
@@ -41,6 +41,75 @@
/// AVCaptureSession otherwise.
class MacNativeBuilderEntitlementsTest {
+ /**
+ * Xcode accepts "${NAME}" as readily as "$(NAME)". Materializing only the parenthesised form
+ * left the brace form unresolved, so the iOS entitlement expanded it against the iOS bundle id
+ * while this one expanded it against the derived Catalyst id -- two slices, two stores.
+ */
+ @Test
+ void theBraceFormOfTheBundleIdIsMaterializedToo(@TempDir Path tmp) throws Exception {
+ BuildRequest req = new BuildRequest();
+ req.setMainClass("MyApp");
+ req.putArgument("macNative.enabled", "true");
+ req.putArgument("macNative.distribution", "developerID");
+ req.setPackageName("com.example.app");
+ req.putArgument("ios.entitlements.com.apple.developer.ubiquity-kvstore-identifier",
+ "$(TeamIdentifierPrefix)${CFBundleIdentifier}");
+
+ String body = writeEntitlements(req, tmp, "MyApp");
+
+ assertFalse(body.contains("${CFBundleIdentifier}"),
+ "the brace form was left unresolved, so the Catalyst slice expands it against the "
+ + "DERIVED mac bundle id: " + body);
+ assertTrue(body.contains("$(TeamIdentifierPrefix)com.example.app"),
+ "the iOS bundle id did not reach the Mac slice: " + body);
+ }
+
+ /**
+ * A Catalyst archive is signed with the plist this writes, and it is assembled from the
+ * macNative.entitlements.* namespace alone. The iCloud key-value store entitlement the iOS
+ * side generates reached the iOS slice and silently missed the Mac one, so
+ * NSUbiquitousKeyValueStore had no container in the Mac slice of the very build that switched
+ * the shared continuity code on -- a runtime failure on a Mac with nothing said at build time.
+ */
+ @Test
+ void theSyncedStoreEntitlementReachesTheCatalystSlice(@TempDir Path tmp) throws IOException {
+ BuildRequest req = new BuildRequest();
+ req.setMainClass("MyApp");
+ req.putArgument("macNative.enabled", "true");
+ req.putArgument("macNative.distribution", "developerID");
+ req.setPackageName("com.example.app");
+ // What IPhoneBuilder puts there when the app references com.codename1.continuity.sync.
+ req.putArgument("ios.entitlements.com.apple.developer.ubiquity-kvstore-identifier",
+ "$(TeamIdentifierPrefix)$(CFBundleIdentifier)");
+
+ String body = writeEntitlements(req, tmp, "MyApp");
+
+ assertTrue(body.contains("com.apple.developer.ubiquity-kvstore-identifier "),
+ "the Mac slice was signed without the key-value store entitlement: " + body);
+ // MATERIALIZED. $(CFBundleIdentifier) is target-relative and the Catalyst target derives
+ // ".maccatalyst", so leaving the expression in signed this slice for a DIFFERENT
+ // container than iOS -- the very failure this entitlement exists to prevent.
+ assertFalse(body.contains("$(CFBundleIdentifier)"),
+ "the Catalyst slice re-evaluates the iOS bundle id, so it signs for "
+ + ".maccatalyst instead: " + body);
+ assertTrue(body.contains("$(TeamIdentifierPrefix)com.example.app"),
+ "the iOS container did not reach the Mac slice: " + body);
+ }
+
+ /** An app that never references the sync package pays nothing on the Mac slice either. */
+ @Test
+ void noSyncedStoreMeansNoCatalystEntitlement(@TempDir Path tmp) throws IOException {
+ BuildRequest req = new BuildRequest();
+ req.setMainClass("MyApp");
+ req.putArgument("macNative.enabled", "true");
+ req.putArgument("macNative.distribution", "developerID");
+
+ String body = writeEntitlements(req, tmp, "MyApp");
+
+ assertFalse(body.contains("ubiquity-kvstore-identifier"), body);
+ }
+
@Test
void appStoreSandboxedAddsCameraAndMicEntitlementsWhenPlistDefaultsAreSet(@TempDir Path tmp)
throws IOException {
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..03343d687ac
--- /dev/null
+++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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"
+ + "UUID 0f7ac3c1-4d0e-4e8a-9d1f-8b6a2c5e7d90 \n"
+ + "ExpirationDate 2099-01-01T00:00:00Z \n"
+ + "DeveloperCertificates Zm9v \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.sync", "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 declared the synced store is not checked. The builder decides that
+ * from bytecode, which this cannot read.
+ */
+ @Test
+ public void aProjectThatDeclaresNoSyncedStoreIsNotChecked() throws Exception {
+ Properties p = settings(profile("NoCloud", false));
+ p.remove("codename1.arg.ios.continuity.sync");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /**
+ * The false warning this check used to produce. A project that uses continuity but NOT the
+ * synced store gets no entitlement from the builder, so warning that its profile cannot sign
+ * one told it to enable an iCloud capability it does not need.
+ */
+ @Test
+ public void aContinuityOnlyProjectIsNotWarnedAboutICloud() throws Exception {
+ Properties p = new Properties();
+ p.setProperty("codename1.packageName", "com.example.app");
+ p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true),
+ profile("NoCloud", false).getAbsolutePath());
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /**
+ * A profile granting one container while the project names another is a build that fails at
+ * codesigning, and this says so before it is sent.
+ *
+ * This test used to assert the opposite, on the reasoning that WHICH container a profile
+ * grants could not be answered from the key alone. That was true only because the parser threw
+ * the value away -- a limitation the code then described as if it were inherent. Keeping the
+ * string makes the comparison ordinary, and an app sharing a sibling's store is exactly when a
+ * project names a container by hand and can name the wrong one.
+ */
+ @Test
+ public void anExplicitContainerTheProfileDoesNotGrantIsWarnedAbout() throws Exception {
+ Properties p = settings(profile("WithCloud", true));
+ p.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared");
+
+ List problems = check(p);
+
+ assertEquals("a container the profile does not grant was not reported",
+ 1, problems.size());
+ assertTrue(problems.get(0).message,
+ problems.get(0).message.contains("ABCD1234.com.example.shared"));
+ assertTrue(problems.get(0).message,
+ problems.get(0).message.contains("ABCD1234.com.example.app"));
+ }
+
+ /**
+ * The container the profile actually grants passes quietly, or the check above would fire on
+ * every correctly configured project that names its container explicitly.
+ */
+ @Test
+ public void theContainerTheProfileGrantsIsLeftAlone() throws Exception {
+ Properties p = settings(profile("WithCloud", true));
+ p.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.app");
+
+ assertTrue(check(p).isEmpty());
+ }
+
+ /**
+ * And anything that is not a literal is left alone on both sides.
+ *
+ * The value the build requests when the project names no container is
+ * "$(TeamIdentifierPrefix)$(CFBundleIdentifier)", two Xcode variables this has no business
+ * expanding, and a profile may grant a wildcard. Comparing either would warn about
+ * configurations that sign perfectly well, and a preflight that cries wolf is one people stop
+ * reading.
+ */
+ @Test
+ public void aVariableOrWildcardContainerIsNotCompared() throws Exception {
+ Properties p = settings(profile("WithCloud", true));
+ p.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "$(TeamIdentifierPrefix)$(CFBundleIdentifier)");
+
+ assertTrue("the Xcode-variable default was compared as a literal", check(p).isEmpty());
+ }
+
+ /**
+ * The brace spelling is a variable too, and so is a value that mixes the two.
+ *
+ * Xcode accepts ${CFBundleIdentifier} equally, replaceBuildSetting() substitutes both, and
+ * this project's own Mac entitlement test writes them in a single value. Recognising only
+ * "$(" does not fail to warn -- it warns WRONGLY, reporting a signing failure that will not
+ * happen for an override Xcode expands correctly.
+ */
+ @Test
+ public void braceStyleAndMixedVariablesAreNotCompared() throws Exception {
+ Properties braces = settings(profile("WithCloudBraces", true));
+ braces.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "${TeamIdentifierPrefix}${CFBundleIdentifier}");
+ assertTrue("a ${...} override was compared as a literal", check(braces).isEmpty());
+
+ // A distinct name: the fixture writes a file named after the profile, so two in one test
+ // collide in the temporary folder.
+ Properties mixed = settings(profile("WithCloudMixed", true));
+ mixed.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "$(TeamIdentifierPrefix)${CFBundleIdentifier}");
+ assertTrue("a value mixing both spellings was compared as a literal",
+ check(mixed).isEmpty());
+ }
+
+ /**
+ * But a profile that grants NO key-value store at all is answerable, and naming a container
+ * does not rescue it: the builder puts the entitlement in either way and codesigning rejects
+ * it. This returned early on the override and suppressed the one warning it can give for
+ * certain -- the unanswerable question is which container, not whether there is one.
+ */
+ @Test
+ public void anExplicitContainerStillWarnsWhenTheProfileGrantsNothing() throws Exception {
+ Properties p = settings(profile("NoCloud", false));
+ p.setProperty("codename1.arg.ios.entitlements.com.apple.developer"
+ + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared");
+
+ List problems = check(p);
+
+ assertEquals(String.valueOf(problems), 1, problems.size());
+ assertTrue("the warning does not name the container the project asked for: "
+ + problems.get(0).message,
+ problems.get(0).message.contains("ABCD1234.com.example.shared"));
+ }
+
+ /** 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.sync", "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/call/LocalCallTest.java b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java
index b11cf898e8e..2f7a2a9dce2 100644
--- a/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java
+++ b/maven/core-unittests/src/test/java/com/codename1/call/LocalCallTest.java
@@ -687,7 +687,7 @@ public void deferringTwiceLeavesNoTimerThreadBehind() {
// a desktop JVM alive long after the call is over. The action being
// answered exactly once hides this completely at the API level; the
// only thing that shows it is the thread.
- int before = liveTimerThreads();
+ java.util.Set before = timerThreads();
final List seen = new ArrayList();
Calls.addActionListener(new CallActionAdapter() {
public void endRequested(String callId, CallAction action) {
@@ -703,26 +703,60 @@ public void endRequested(String callId, CallAction action) {
seen.get(0).fulfill();
long limit = System.currentTimeMillis() + 2000;
- while (liveTimerThreads() > before
+ while (!survivingNewTimers(before).isEmpty()
&& System.currentTimeMillis() < limit) {
sleep();
}
- assertEquals(before, liveTimerThreads(),
- "answering must leave no safety timer running");
- }
-
- /// Live java.util.Timer threads, which is where a leaked safety net shows.
- private static int liveTimerThreads() {
- int n = 0;
+ // IDENTITY, not a count. These threads are JVM-wide -- anything named "Timer-" -- so a
+ // count compares this call's safety timer against every other timer in the run, and
+ // arithmetic hides the thing under test: two unrelated timers expiring inside the window
+ // offsets one that leaked, and the assertion passes.
+ //
+ // That is not hypothetical. This started as an equality check, failed with "expected 4
+ // but was 2" when two unrelated timers finished -- which is correct behaviour and nothing
+ // to do with call deferral -- and was weakened to "not more than before" to quiet it. The
+ // weakening made it pass for the wrong reason instead of fixing what it measured.
+ //
+ // Tracking which threads existed BEFORE answers the actual question: did answering leave
+ // a timer of its own running. Unrelated timers may start or stop freely and are never
+ // counted, because they are compared by identity rather than by number.
+ java.util.Set leaked = survivingNewTimers(before);
+ assertTrue(leaked.isEmpty(),
+ "answering left a safety timer running: " + names(leaked));
+ }
+
+ /// The live java.util.Timer threads, BY IDENTITY, which is where a leaked safety net shows.
+ private static java.util.Set timerThreads() {
+ java.util.Set out =
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap());
Thread[] all = new Thread[Thread.activeCount() * 2 + 16];
int found = Thread.enumerate(all);
for (int i = 0; i < found; i++) {
Thread t = all[i];
if (t != null && t.isAlive() && t.getName().startsWith("Timer-")) {
- n++;
+ out.add(t);
+ }
+ }
+ return out;
+ }
+
+ /// Timer threads that are alive now and were not alive before -- the only ones this action
+ /// can be held responsible for.
+ private static java.util.Set survivingNewTimers(java.util.Set before) {
+ java.util.Set now = timerThreads();
+ now.removeAll(before);
+ return now;
+ }
+
+ private static String names(java.util.Set threads) {
+ StringBuilder sb = new StringBuilder();
+ for (Thread t : threads) {
+ if (sb.length() > 0) {
+ sb.append(", ");
}
+ sb.append(t.getName());
}
- return n;
+ return sb.toString();
}
@Test
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..1a6d1e10cc4
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java
@@ -0,0 +1,677 @@
+/*
+ * 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.io.IOException;
+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.
+ */
+// Extends UITestBase, as every other test in this package does, and not for the EDT: core code
+// logs, and Log.print() reaches Display.getInstance() the first time it runs -- so in a class
+// with no Display the FIRST test that makes the framework log dies with a NullPointerException
+// out of Util.cleanup(). Which test that is depends on the order they happen to run in, so the
+// class passed until an unrelated edit moved a different one to the front.
+public class AppStateWireTest extends com.codename1.junit.UITestBase {
+
+ @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());
+ }
+
+ /**
+ * The reason every scalar crosses as a tagged string.
+ *
+ * {@code JSONParser} reads every JSON number back as a {@code Double} and reads
+ * {@code true} back as the string {@code "true"}. Without tagging, an application
+ * that stored an {@code Integer} and cast it back got a {@code ClassCastException} on Android
+ * and the desktop -- and on iOS something worse, because ParparVM does not throw for a failed
+ * cast and hands the wrong object to the next instruction.
+ */
+ @Test
+ public void everyAdmittedScalarKeepsItsTypeThroughJson() throws Exception {
+ Map payload = new HashMap();
+ payload.put("i", Integer.valueOf(3));
+ payload.put("l", Long.valueOf(9007199254740993L));
+ payload.put("d", Double.valueOf(1.5));
+ payload.put("b", Boolean.TRUE);
+ payload.put("s", "text");
+
+ Map back = StateCodec.fromJson(
+ StateCodec.toJson(new AppState().setPayload(payload))).getPayload();
+
+ assertEquals(Integer.valueOf(3), back.get("i"));
+ assertEquals(Long.valueOf(9007199254740993L), back.get("l"),
+ "a long past 2^53 is a different number once it has been a double");
+ assertEquals(Double.valueOf(1.5), back.get("d"));
+ assertEquals(Boolean.TRUE, back.get("b"));
+ assertEquals("text", back.get("s"));
+ }
+
+ /** The same guarantee on the map form, which is what an Apple continuation carries. */
+ @Test
+ public void everyAdmittedScalarKeepsItsTypeThroughTheMapForm() {
+ Map payload = new HashMap();
+ payload.put("i", Integer.valueOf(42));
+ payload.put("b", Boolean.FALSE);
+ payload.put("l", Long.valueOf(-9007199254740993L));
+
+ Map back = StateCodec.fromMap(
+ StateCodec.toMap(new AppState().setPayload(payload))).getPayload();
+
+ assertEquals(Integer.valueOf(42), back.get("i"));
+ assertEquals(Boolean.FALSE, back.get("b"));
+ assertEquals(Long.valueOf(-9007199254740993L), back.get("l"));
+ }
+
+ /** Types survive inside a list and inside a nested map too. */
+ @Test
+ public void typesSurviveInsideListsAndNestedMaps() throws Exception {
+ Map inner = new HashMap();
+ inner.put("count", Integer.valueOf(9));
+ List list = new ArrayList();
+ list.add(Integer.valueOf(7));
+ list.add(Boolean.TRUE);
+ list.add("nine");
+ Map payload = new HashMap();
+ payload.put("inner", inner);
+ payload.put("list", list);
+
+ Map back = StateCodec.fromJson(
+ StateCodec.toJson(new AppState().setPayload(payload))).getPayload();
+
+ assertEquals(Integer.valueOf(9), ((Map, ?>) back.get("inner")).get("count"));
+ List> readList = (List>) back.get("list");
+ assertEquals(Integer.valueOf(7), readList.get(0));
+ assertEquals(Boolean.TRUE, readList.get(1));
+ assertEquals("nine", readList.get(2));
+ }
+
+ /** An application's own tag-shaped string is not mistaken for a tagged value. */
+ @Test
+ public void aStringThatLooksLikeATagIsStillAString() throws Exception {
+ Map payload = new HashMap();
+ payload.put("looksLikeAnInt", "i:5");
+ payload.put("looksLikeABool", "b:true");
+
+ Map back = StateCodec.fromJson(
+ StateCodec.toJson(new AppState().setPayload(payload))).getPayload();
+
+ assertEquals("i:5", back.get("looksLikeAnInt"));
+ assertEquals("b:true", back.get("looksLikeABool"));
+ }
+
+ /**
+ * An untagged payload -- a hand-written endpoint, or a device on an older build -- is passed
+ * through rather than refused. Untyped beats absent.
+ */
+ @Test
+ public void anUntaggedValueFromElsewhereIsPassedThrough() throws Exception {
+ AppState back = StateCodec.fromJson(
+ "{\"device\":\"other\",\"payload\":{\"note\":\"plain\"}}");
+
+ assertEquals("plain", back.getPayload().get("note"));
+ }
+
+ /**
+ * Null is refused where the application can act on it.
+ *
+ * A property list cannot carry one: the iOS sanitizer drops a null-valued entry, and drops
+ * a null LIST ELEMENT, which shifts every index after it -- so the payload arriving on the
+ * other device is a different shape from the one that was sent.
+ */
+ /**
+ * A NEGATIVE timestamp is refused rather than read as "this state carries no time".
+ *
+ *
Zero is the documented absent value and isTooOld() reads anything not positive that way,
+ * so a negative ts arriving from a custom relay or a compatibility sender produced a state
+ * that could never expire -- whatever maxAge the application had configured. An expired
+ * checkout or a released booking hold would go on being restorable for the life of the
+ * install, which is the one thing maxAge exists to stop.
+ *
+ * Refused at the wire boundary rather than clamped, for the reason the sequence check
+ * gives: a value this codec silently repaired would differ from what the sender believes it
+ * sent, and the two sides then disagree about a state neither can see.
+ */
+ @Test
+ public void aNegativeTimestampIsRefused() {
+ String doc = "{\"routes\":[\"/a\"],\"device\":\"phone\",\"seq\":\"3\","
+ + "\"ts\":\"-1\"}";
+
+ assertThrows(IOException.class, new org.junit.jupiter.api.function.Executable() {
+ public void execute() throws Throwable {
+ StateCodec.fromJson(doc);
+ }
+ }, "a negative timestamp was accepted, so the state can never expire");
+ }
+
+ /**
+ * And the extreme of the same value, which is also where the arithmetic would go wrong.
+ *
+ * Long.MIN_VALUE overflows the subtraction isTooOld() would make, and the only reason it
+ * does not today is the positive-guard that this same malformed value hides behind. Refusing
+ * it at the boundary is what keeps both true at once.
+ */
+ @Test
+ public void theMostNegativeTimestampIsRefused() {
+ String doc = "{\"routes\":[\"/a\"],\"device\":\"phone\",\"seq\":\"3\","
+ + "\"ts\":\"" + Long.MIN_VALUE + "\"}";
+
+ assertThrows(IOException.class, new org.junit.jupiter.api.function.Executable() {
+ public void execute() throws Throwable {
+ StateCodec.fromJson(doc);
+ }
+ }, "Long.MIN_VALUE was accepted as a timestamp");
+ }
+
+ @Test
+ public void aNullPayloadValueIsRefusedWithItsKey() {
+ final Map payload = new HashMap();
+ payload.put("draft", null);
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("draft"), err.getMessage());
+ assertTrue(err.getMessage().contains("null"), err.getMessage());
+ }
+
+ @Test
+ public void aNullInsideAListIsRefusedWithItsIndex() {
+ List list = new ArrayList();
+ list.add("fine");
+ list.add(null);
+ final Map payload = new HashMap();
+ payload.put("items", list);
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("items[1]"), err.getMessage());
+ }
+
+ @Test
+ public void aNestedPayloadSurvivesTheMapForm() {
+ Map inner = new HashMap();
+ inner.put("street", "Sesame");
+ List list = new ArrayList();
+ list.add("a");
+ list.add(Integer.valueOf(2));
+ list.add(Boolean.TRUE);
+ Map payload = new HashMap();
+ payload.put("address", inner);
+ payload.put("tags", list);
+
+ AppState back = StateCodec.fromMap(StateCodec.toMap(new AppState().setPayload(payload)));
+
+ assertNotNull(back);
+ Object address = back.getPayload().get("address");
+ assertTrue(address instanceof Map);
+ assertEquals("Sesame", ((Map, ?>) address).get("street"));
+ assertEquals(3, ((List>) back.getPayload().get("tags")).size());
+ }
+
+ @Test
+ public void anUnrepresentableValueIsRefusedWithItsKey() {
+ Map payload = new HashMap();
+ payload.put("when", new java.util.Date());
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("when"), err.getMessage());
+ assertTrue(err.getMessage().contains("java.util.Date"), err.getMessage());
+ }
+
+ @Test
+ public void anUnrepresentableValueNestedInsideAListNamesItsPath() {
+ List list = new ArrayList();
+ list.add("fine");
+ list.add(new Object());
+ Map payload = new HashMap();
+ payload.put("items", list);
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("items[1]"), err.getMessage());
+ }
+
+ /**
+ * A cycle looks exactly like a very deep tree until the stack runs out, and neither
+ * destination format can represent one.
+ */
+ @Test
+ public void aCyclicPayloadIsRefusedRatherThanOverflowingTheStack() {
+ Map payload = new HashMap();
+ List loop = new ArrayList();
+ loop.add(loop);
+ payload.put("loop", loop);
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("cycle"), err.getMessage());
+ }
+
+ @Test
+ public void aMapKeyThatIsNotAStringIsRefused() {
+ Map inner = new HashMap();
+ inner.put(Integer.valueOf(1), "one");
+ Map payload = new HashMap();
+ payload.put("byNumber", inner);
+
+ IllegalArgumentException err = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setPayload(payload);
+ }
+ });
+
+ assertTrue(err.getMessage().contains("byNumber"), err.getMessage());
+ }
+
+ /**
+ * A payload arriving from another device gets the SAME check a local one gets, and a failure
+ * is a failed read rather than an exception.
+ *
+ * This test used to assert the opposite, on the reasoning that the payload was validated
+ * where it was produced and that refusing it here would turn a remote build's mistake into an
+ * exception on this device. The second half stopped being true when fromMap() began answering
+ * null for a bad field type: a refusal is a failed READ, so the document stays on the relay
+ * for a build that can use it and nothing is thrown at the user.
+ *
+ * The first half does not survive either. Accepting an unrepresentable value does not make
+ * it work -- it reaches the listeners and the provider, is acknowledged, and then throws out
+ * of externalize() when the checkpoint is written, which is the failure that leaves `dirty`
+ * set and retries for ever. A null nested in a list is worse than that: it survives to the
+ * iOS property-list sanitizer, which drops it and shifts every index after it, so the
+ * application's data quietly changes shape between one device and the next.
+ */
+ @Test
+ public void anArrivingPayloadIsCheckedLikeALocalOne() {
+ Map wire = new HashMap();
+ Map payload = new HashMap();
+ payload.put("odd", new Object());
+ wire.put("payload", payload);
+ wire.put("device", "other");
+
+ assertNull(StateCodec.fromMap(wire),
+ "an unrepresentable value arrived intact, so it reaches the application and then "
+ + "breaks the checkpoint that tries to store it");
+
+ // A null nested in a LIST, which is the shape the iOS sanitizer silently reindexes.
+ Map nulled = new HashMap();
+ Map withList = new HashMap();
+ withList.put("items", Arrays.asList("a", null, "b"));
+ nulled.put("payload", withList);
+ nulled.put("device", "other");
+ assertNull(StateCodec.fromMap(nulled),
+ "a null list element arrived intact, so the list is one shorter on an iPad than "
+ + "on the device that sent it");
+
+ // What a conforming sender writes still goes through, or this guard would refuse every
+ // arrival there is.
+ Map fine = new HashMap();
+ Map goodPayload = new HashMap();
+ goodPayload.put("items", Arrays.asList("a", "b"));
+ goodPayload.put("n", Integer.valueOf(3));
+ fine.put("payload", goodPayload);
+ fine.put("device", "other");
+ AppState back = StateCodec.fromMap(fine);
+ assertNotNull(back, "a conforming payload was refused");
+ assertEquals("other", back.getDeviceId());
+ assertEquals(Integer.valueOf(3), back.getPayload().get("n"));
+ }
+
+ @Test
+ public void anUnknownFieldFromANewerBuildIsIgnoredRatherThanFailing() throws Exception {
+ AppState back = StateCodec.fromJson(
+ "{\"routes\":[\"/home\"],\"device\":\"x\",\"somethingNew\":{\"a\":1}}");
+
+ assertNotNull(back);
+ assertEquals(Arrays.asList("/home"), back.getRoutes());
+ }
+
+ @Test
+ public void emptyAndNullDocumentsProduceNoState() throws Exception {
+ assertNull(StateCodec.fromJson(null));
+ assertNull(StateCodec.fromJson(" "));
+ assertNull(StateCodec.fromMap(null));
+ }
+
+ /**
+ * A relay answering {@code {}}, or an activity arriving with no usable userInfo, is not a
+ * state. Returning a default one meant the continuation callback CLAIMED it and delivered it:
+ * the application's listeners ran, and an app that prompts before moving the user put a
+ * "continue what you were doing?" dialog in front of them over nothing at all.
+ *
+ * That harm is about FABRICATING a state and it is still asserted below, on the path where
+ * it happens: fromMap answers null, so the continuation callback declines rather than
+ * claiming. What has changed is the RELAY's reading of a document that carries fields none of
+ * which are ours -- null there means "the relay holds nothing", which releases the publisher
+ * and overwrites a document this device never read. An empty object stays null, because that
+ * is a plausible way for an endpoint to say it holds nothing.
+ */
+ @Test
+ public void aDocumentWithNoStateFieldsIsNotAState() throws Exception {
+ assertNull(StateCodec.fromJson("{}"));
+ assertNull(StateCodec.fromMap(new HashMap()));
+
+ // No state is FABRICATED for an unrecognised document -- the guarantee this test was
+ // written for, on the path it applies to.
+ Map unrelated = new HashMap();
+ unrelated.put("somethingElse", Integer.valueOf(1));
+ unrelated.put("unrelated", "x");
+ assertNull(StateCodec.fromMap(unrelated),
+ "an unrecognised activity produced a state, so the callback claims it and the "
+ + "application is prompted over nothing at all");
+
+ // And on the relay wire the same document is a failed READ, not an empty relay: null
+ // there means "the relay holds nothing", which releases the publisher and overwrites a
+ // document this device never read.
+ Exception unreadable = assertThrows(Exception.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() throws Throwable {
+ StateCodec.fromJson("{\"somethingElse\":1,\"unrelated\":\"x\"}");
+ }
+ });
+ assertTrue(unreadable.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ /** One recognized field is enough -- a state with only routes is a real state. */
+ @Test
+ public void aDocumentWithAnyKnownFieldIsAState() throws Exception {
+ assertNotNull(StateCodec.fromJson("{\"routes\":[\"/home\"]}"));
+ assertNotNull(StateCodec.fromJson("{\"device\":\"other\"}"));
+ assertNotNull(StateCodec.fromJson("{\"ts\":\"1\"}"));
+ }
+
+ @Test
+ public void blankRoutePathsAreDropped() {
+ AppState state = new AppState().setRoutes(Arrays.asList("/a", null, "", "/b"));
+
+ assertEquals(Arrays.asList("/a", "/b"), state.getRoutes());
+ }
+
+ @Test
+ public void aStateWithNoRoutesAndNoPayloadIsEmpty() {
+ assertTrue(new AppState().isEmpty());
+ assertFalse(new AppState().setRoutes(Arrays.asList("/a")).isEmpty());
+ }
+
+ private static AppState sample() {
+ Map payload = new HashMap();
+ payload.put("name", "Ada");
+ return new AppState()
+ .setRoutes(Arrays.asList("/home", "/users/42"))
+ .setPayload(payload)
+ .setDeviceId("device-a")
+ .setTitle("Editing Ada")
+ .setSequence(7L)
+ .setTimestamp(1700000000123L);
+ }
+
+ /**
+ * Util.writeObject writes every String with DataOutputStream.writeUTF, which cannot encode
+ * more than 65535 bytes and throws when asked to. Continuity.persist() logs that and carries
+ * on, so an oversized payload produced a checkpoint that LOOKED successful and simply was not
+ * there after the process died -- state restoration failing silently at exactly the moment it
+ * exists for. Refused up front instead, naming the key.
+ */
+ @Test
+ void anOversizedPayloadStringIsRefusedNamingTheKey() {
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ Map payload = new HashMap();
+ payload.put("draft", huge.toString());
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ StateCodec.requireRepresentable(payload);
+ }
+ });
+
+ assertTrue(e.getMessage().contains("draft"), e.getMessage());
+ assertTrue(e.getMessage().contains("65535"), e.getMessage());
+ }
+
+ /** The limit is on BYTES: a CJK string reaches it at a third of the character count. */
+ @Test
+ void theLimitCountsBytesNotCharacters() {
+ StringBuilder cjk = new StringBuilder();
+ for (int i = 0; i < 30000; i++) {
+ cjk.append('\u4e2d');
+ }
+ Map payload = new HashMap();
+ payload.put("note", cjk.toString());
+
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ StateCodec.requireRepresentable(payload);
+ }
+ });
+ }
+
+ /**
+ * Every string this class writes goes through Util.writeUTF, not just the payload. A route
+ * carrying a long query value, or a long title, made externalize() throw -- which
+ * Continuity.persist() logs and carries on from, so the checkpoint reached the other device
+ * and was silently absent from local storage.
+ */
+ @Test
+ void everyStringSurfaceIsLengthChecked() {
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ final String big = huge.toString();
+
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setRoutes(java.util.Arrays.asList("/ok", "/x?q=" + big));
+ }
+ });
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setTitle(big);
+ }
+ });
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ new AppState().setDeviceId(big);
+ }
+ });
+ }
+
+ /**
+ * A nested map key reaches the same writeUTF as a top-level one. Validating only the top level
+ * left a deep key able to throw inside externalize(), which persist() logs and carries on
+ * from -- so the checkpoint went to the other device and was silently absent locally.
+ */
+ @Test
+ void anOversizedNestedMapKeyIsRefused() {
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('k');
+ }
+ Map inner = new HashMap();
+ inner.put(huge.toString(), "value");
+ final Map payload = new HashMap();
+ payload.put("outer", inner);
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ StateCodec.requireRepresentable(payload);
+ }
+ });
+
+ assertTrue(e.getMessage().contains("65535"), e.getMessage());
+ // The key itself is the oversized thing; the message names it without reproducing it.
+ assertTrue(e.getMessage().length() < 2000,
+ "the message reproduced the whole key: " + e.getMessage().length() + " chars");
+ }
+
+ /**
+ * An untagged payload is read exactly as it arrived. decode() used to ask of every string
+ * whether it looked tagged, so a hand-written endpoint or an older build sending the ordinary
+ * string "i:5" had it turned into an Integer, and "s:note" silently lost its prefix. No
+ * per-string rule can separate those, because "i:5" is a perfectly good string -- the
+ * document says once whether its values are tagged.
+ */
+ @Test
+ public void anUntaggedPayloadKeepsStringsThatLookLikeTags() {
+ Map payload = new HashMap();
+ payload.put("looksLikeAnInt", "i:5");
+ payload.put("looksLikeAString", "s:note");
+ Map doc = new HashMap();
+ doc.put("device", "some-other-device");
+ doc.put("seq", "3");
+ doc.put("payload", payload);
+ // No "enc" marker: this is what a hand-written endpoint produces.
+
+ AppState state = StateCodec.fromMap(doc);
+
+ assertNotNull(state);
+ assertEquals("i:5", state.getPayload().get("looksLikeAnInt"));
+ assertEquals("s:note", state.getPayload().get("looksLikeAString"));
+ }
+
+ /** And a document this codec wrote still round-trips its types. */
+ @Test
+ public void aTaggedDocumentStillRoundTripsItsTypes() {
+ Map payload = new HashMap();
+ payload.put("count", Integer.valueOf(5));
+ payload.put("note", "i:5");
+ AppState state = new AppState().setPayload(payload).setDeviceId("d").setSequence(1L);
+
+ AppState back = StateCodec.fromMap(StateCodec.toMap(state));
+
+ assertEquals(Integer.valueOf(5), back.getPayload().get("count"));
+ assertEquals("i:5", back.getPayload().get("note"));
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/ContinuityDegradationTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/ContinuityDegradationTest.java
new file mode 100644
index 00000000000..90e769af18d
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/continuity/ContinuityDegradationTest.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation. Codename One designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Codename One through http://www.codenameone.com/ if you
+ * need additional information or have any questions.
+ */
+package com.codename1.continuity;
+
+import com.codename1.continuity.sync.SyncedStore;
+import com.codename1.junit.EdtTest;
+import com.codename1.junit.UITestBase;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * A port that implements nothing.
+ *
+ * This is the ordinary case for Android, the desktop and the browser, and it is the case that
+ * has to stay boring: an app that references the continuity API and runs where the platform
+ * carries nothing between devices must get honest answers, not exceptions. Every entry point is
+ * exercised here precisely because none of them is interesting.
+ *
+ * Note what is NOT unsupported on such a port: saving and restoring state on the device itself.
+ * That half is pure storage and has no bridge behind it at all, which is why it is tested
+ * elsewhere rather than here.
+ */
+public class ContinuityDegradationTest extends UITestBase {
+
+ @BeforeEach
+ public void noBridge() {
+ Continuity.reset();
+ Continuity.setBridge(new NullContinuityBridge());
+ Continuity.enable();
+ }
+
+ @AfterEach
+ public void clear() {
+ Continuity.reset();
+ }
+
+ @EdtTest
+ public void everyCapabilityQueryAnswersFalselyRatherThanThrowing() {
+ assertFalse(Continuity.isContinuationSupported());
+ assertFalse(SyncedStore.isSupported());
+ }
+
+ @EdtTest
+ public void publishingAContinuationIsAnInertNoOp() {
+ Continuity.setTitle("Something");
+ Continuity.checkpoint();
+ }
+
+ @EdtTest
+ public void theSyncedStoreAnswersWithTheDefaultAndKeepsNothing() {
+ assertFalse(SyncedStore.put("sortOrder", "byDate"));
+ assertEquals("byName", SyncedStore.get("sortOrder", "byName"));
+ SyncedStore.remove("sortOrder");
+ assertArrayEquals(new String[0], SyncedStore.keys());
+ }
+
+ /**
+ * The argument checks are NOT part of the degradation.
+ *
+ * A null key is a programming error wherever it happens, and letting it pass silently on
+ * the ports where the store does nothing means it is found for the first time on the one port
+ * where it does something.
+ */
+ @EdtTest
+ public void argumentMistakesStillFailOnAPortWithNoStore() {
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ SyncedStore.get(null, "x");
+ }
+ });
+ assertThrows(IllegalArgumentException.class,
+ new org.junit.jupiter.api.function.Executable() {
+ public void execute() {
+ SyncedStore.put("k", null);
+ }
+ });
+ }
+
+ /**
+ * A bridge that throws from everything, which is what a port mid-failure looks like.
+ *
+ * The framework runs on housekeeping paths -- a navigation, a suspend -- so an exception
+ * escaping one of them takes down a flow that has nothing to do with continuity.
+ */
+ @EdtTest
+ public void aBridgeThatThrowsFromEverythingDoesNotEscape() {
+ Continuity.setBridge(new ThrowingContinuityBridge());
+
+ assertFalse(Continuity.isContinuationSupported());
+ assertFalse(SyncedStore.isSupported());
+ assertFalse(SyncedStore.put("k", "v"));
+ assertEquals("d", SyncedStore.get("k", "d"));
+ SyncedStore.remove("k");
+ assertArrayEquals(new String[0], SyncedStore.keys());
+ Continuity.checkpoint();
+ Continuity.disable();
+ }
+
+ /** Reports nothing supported and records nothing. */
+ static class NullContinuityBridge implements com.codename1.continuity.spi.ContinuityBridge {
+ public void setCallback(com.codename1.continuity.spi.ContinuityCallback callback) {
+ }
+
+ public boolean isContinuationSupported() {
+ return false;
+ }
+
+ public void publishContinuation(String activityType, String title,
+ java.util.Map userInfo) {
+ throw new IllegalStateException("must not be called when unsupported");
+ }
+
+ public void clearContinuation() {
+ }
+
+ public boolean isSyncedStoreSupported() {
+ return false;
+ }
+
+ public boolean syncedStorePut(String key, String value) {
+ throw new IllegalStateException("must not be called when unsupported");
+ }
+
+ public String syncedStoreGet(String key) {
+ throw new IllegalStateException("must not be called when unsupported");
+ }
+
+ public void syncedStoreRemove(String key) {
+ throw new IllegalStateException("must not be called when unsupported");
+ }
+
+ public String[] syncedStoreKeys() {
+ throw new IllegalStateException("must not be called when unsupported");
+ }
+ }
+
+ /** Throws from every method, including the capability queries. */
+ static class ThrowingContinuityBridge implements com.codename1.continuity.spi.ContinuityBridge {
+ public void setCallback(com.codename1.continuity.spi.ContinuityCallback callback) {
+ throw new IllegalStateException("boom");
+ }
+
+ public boolean isContinuationSupported() {
+ throw new IllegalStateException("boom");
+ }
+
+ public void publishContinuation(String activityType, String title,
+ java.util.Map userInfo) {
+ throw new IllegalStateException("boom");
+ }
+
+ public void clearContinuation() {
+ throw new IllegalStateException("boom");
+ }
+
+ public boolean isSyncedStoreSupported() {
+ throw new IllegalStateException("boom");
+ }
+
+ public boolean syncedStorePut(String key, String value) {
+ throw new IllegalStateException("boom");
+ }
+
+ public String syncedStoreGet(String key) {
+ throw new IllegalStateException("boom");
+ }
+
+ public void syncedStoreRemove(String key) {
+ throw new IllegalStateException("boom");
+ }
+
+ public String[] syncedStoreKeys() {
+ throw new IllegalStateException("boom");
+ }
+ }
+}
diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java
new file mode 100644
index 00000000000..03f950e72cf
--- /dev/null
+++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java
@@ -0,0 +1,8820 @@
+/*
+ * 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.ContinuityCallback;
+import com.codename1.continuity.sync.SyncedStore;
+import com.codename1.continuity.sync.SyncedStoreListener;
+import com.codename1.impl.continuity.LocalContinuityBridge;
+import com.codename1.io.ConnectionRequest;
+import com.codename1.io.Storage;
+import com.codename1.io.rest.RequestBuilder;
+import com.codename1.io.rest.Rest;
+import com.codename1.junit.EdtTest;
+import com.codename1.router.Navigation;
+import com.codename1.router.RouteDispatcher;
+import com.codename1.ui.Display;
+import com.codename1.ui.Form;
+import com.codename1.junit.UITestBase;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+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.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * The framework against the simulated platform every non-Apple port and the simulator use.
+ *
+ * Everything here is real code: a real {@link Storage}, the real checkpoint, the real
+ * dedup and the real inbound dispatch. Only the operating system is simulated, which is exactly
+ * the split the {@link LocalContinuityBridge} exists to create.
+ */
+public class LocalContinuityTest extends UITestBase {
+
+ private LocalContinuityBridge bridge;
+
+ /// Store listeners this test registered. Removed rather than reset wholesale: the framework
+ /// deliberately offers no public way to clear them, so a test has to unwind exactly what it
+ /// did -- which is also what an application has to do.
+ private final List registered = new ArrayList();
+
+ @BeforeEach
+ public void installBridge() {
+ Continuity.reset();
+ Storage.getInstance().clearStorage();
+ // The delivery high-water marks are DURABLE by design, so they outlive reset() -- which
+ // is the whole point of them, and which makes them leak from one test into the next
+ // unless each starts from a clean slate. clearStorage() above now covers them: they moved
+ // out of Preferences, which cannot report a failed write, and into Storage, which can.
+ bridge = new LocalContinuityBridge();
+ Continuity.setBridge(bridge);
+ // A running application has a form on screen, and the framework deliberately holds an
+ // arriving state until one exists -- a continuation can cold-launch the app, and both
+ // Apple delegates hand it over while init/start are still queued. Without this every
+ // inbound test would exercise the cold-launch hold rather than the delivery it means to.
+ new Form("continuity").show();
+ }
+
+ @AfterEach
+ public void clearFramework() {
+ for (int i = 0; i < registered.size(); i++) {
+ SyncedStore.removeChangeListener(registered.get(i));
+ }
+ registered.clear();
+ Continuity.reset();
+ Storage.getInstance().clearStorage();
+ }
+
+ // ------------------------------------------------------------------
+ // Nothing happens until the application opts in
+ // ------------------------------------------------------------------
+
+ /// Runs a blocking wait OFF the event thread.
+ ///
+ /// Every relay round trip now finishes with a callSerially: the worker hands its answer back
+ /// to the event thread rather than touching framework state itself. A test that blocks the
+ /// EDT waiting for one therefore waits for a runnable queued behind its own wait, and the
+ /// harness reports "pendingSerialCalls=1". invokeAndBlock releases the EDT for the duration,
+ /// which is what an application doing a long wait does too.
+ private static void awaitOffEdt(Runnable r) {
+ Display.getInstance().invokeAndBlock(r);
+ }
+
+ /// Sleeps without holding the event thread. See awaitOffEdt.
+ private static void pause(final long millis) {
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(millis);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ }
+
+ /**
+ * The single most important property of this feature: an app that never touches it behaves
+ * exactly as it always did.
+ */
+ @EdtTest
+ public void nothingIsSavedUntilTheApplicationEnablesTheFramework() {
+ assertFalse(Continuity.isEnabled());
+
+ Continuity.routeStackChanged();
+ Continuity.checkpoint();
+ flushSerialCalls();
+
+ assertFalse(Storage.getInstance().exists(Continuity.STORAGE_KEY));
+ assertNull(Continuity.getRestorableState());
+ assertFalse(Continuity.restore());
+ }
+
+ @EdtTest
+ public void settingAStateProviderEnablesTheFramework() {
+ Continuity.setStateProvider(new RecordingProvider());
+
+ assertTrue(Continuity.isEnabled());
+ }
+
+ // ------------------------------------------------------------------
+ // Saving
+ // ------------------------------------------------------------------
+
+ @EdtTest
+ public void aCheckpointWritesThePayloadAndCanBeReadBack() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("draft", "half a sentence");
+ Continuity.setStateProvider(provider);
+
+ Continuity.checkpoint();
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored);
+ assertEquals("half a sentence", stored.getPayload().get("draft"));
+ assertEquals(Continuity.getDeviceId(), stored.getDeviceId());
+ assertTrue(stored.getTimestamp() > 0);
+ }
+
+ /**
+ * The coalescing rule: a burst of navigations costs one write, not one per navigation.
+ */
+ @EdtTest
+ public void aBurstOfRouteChangesCollapsesIntoOneCheckpoint() {
+ CountingProvider provider = new CountingProvider();
+ Continuity.setStateProvider(provider);
+
+ Continuity.routeStackChanged();
+ Continuity.routeStackChanged();
+ Continuity.routeStackChanged();
+ flushSerialCalls();
+
+ assertEquals(1, provider.saves);
+ }
+
+ /**
+ * The sequence increases with every state, which is what lets a receiver tell a state it has
+ * already acted on from a new one. Two states can share a timestamp -- clocks are coarse --
+ * so the timestamp cannot carry this on its own.
+ */
+ @EdtTest
+ public void everyCheckpointGetsAHigherSequence() {
+ Continuity.setStateProvider(new RecordingProvider());
+
+ Continuity.checkpoint();
+ long first = Continuity.getRestorableState().getSequence();
+ Continuity.checkpoint();
+ long second = Continuity.getRestorableState().getSequence();
+
+ assertTrue(second > first, second + " should be greater than " + first);
+ }
+
+ /**
+ * A provider that throws must not take down the navigation that triggered the checkpoint.
+ * The routes are still saved; only the payload is absent from that one state.
+ */
+ @EdtTest
+ public void aProviderThatThrowsCostsOnlyItsOwnPayload() {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ throw new IllegalStateException("boom");
+ }
+
+ public void restoreState(Map payload) {
+ }
+ });
+
+ Continuity.checkpoint();
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored);
+ assertTrue(stored.getPayload().isEmpty());
+ }
+
+ /**
+ * An unrepresentable payload is NOT swallowed. It is a programming error with exactly one
+ * useful moment to surface -- here, naming the key -- rather than a value that silently stops
+ * arriving on the other device.
+ */
+ @EdtTest
+ public void anUnrepresentablePayloadFailsTheCheckpointLoudly() {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ Map m = new HashMap();
+ m.put("when", new java.util.Date());
+ return m;
+ }
+
+ public void restoreState(Map payload) {
+ }
+ });
+
+ try {
+ Continuity.checkpoint();
+ org.junit.jupiter.api.Assertions.fail("expected the unrepresentable value to be "
+ + "refused");
+ } catch (IllegalArgumentException expected) {
+ assertTrue(expected.getMessage().contains("when"), expected.getMessage());
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Restoring
+ // ------------------------------------------------------------------
+
+ @EdtTest
+ public void restoringHandsThePayloadBackToTheProvider() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("draft", "half a sentence");
+ Continuity.setStateProvider(provider);
+ Continuity.checkpoint();
+
+ boolean shownAForm = Continuity.restore();
+
+ // No routes were recorded, so the framework showed nothing and says so -- which is what
+ // lets "restore, or else begin" work for an app that does not use @Route.
+ assertFalse(shownAForm);
+ assertEquals("half a sentence", provider.restored.get("draft"));
+ }
+
+ @EdtTest
+ public void aStateOlderThanTheMaxAgeIsNotOffered() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.checkpoint();
+ assertNotNull(Continuity.getRestorableState());
+
+ Continuity.setMaxAge(1L);
+ // The stored state's timestamp is now, so age it rather than waiting.
+ AppState aged = Continuity.getRestorableState().setTimestamp(
+ System.currentTimeMillis() - 5000L);
+ Storage.getInstance().writeObject(Continuity.STORAGE_KEY, aged);
+
+ assertNull(Continuity.getRestorableState());
+ }
+
+ @EdtTest
+ public void clearForgetsTheStoredStateAndTheAdvertisedActivity() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("draft", "something");
+ Continuity.setStateProvider(provider);
+ Continuity.checkpoint();
+ assertNotNull(bridge.getPublishedInfo());
+
+ Continuity.clear();
+
+ assertNull(Continuity.getRestorableState());
+ assertNull(bridge.getPublishedType());
+ }
+
+ // ------------------------------------------------------------------
+ // Continuation to and from another device
+ // ------------------------------------------------------------------
+
+ @EdtTest
+ public void aCheckpointAdvertisesTheStateUnderThisAppsActivityType() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("draft", "hello");
+ Continuity.setStateProvider(provider);
+ Continuity.setTitle("Editing a draft");
+
+ Continuity.checkpoint();
+
+ assertEquals(Continuity.getActivityType(), bridge.getPublishedType());
+ assertEquals("Editing a draft", bridge.getPublishedTitle());
+ AppState advertised = StateCodec.fromMap(bridge.getPublishedInfo());
+ assertNotNull(advertised);
+ assertEquals("hello", advertised.getPayload().get("draft"));
+ }
+
+ /**
+ * This device's own echo is never acted on. A relay returns the state this device just
+ * published as a matter of course, and restoring it would move the user to where they
+ * already are -- repeatedly.
+ */
+ @EdtTest
+ public void thisDevicesOwnEchoIsIgnored() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("draft", "something");
+ Continuity.setStateProvider(provider);
+ Continuity.checkpoint();
+
+ boolean claimed = bridge.simulateArrival(Continuity.getActivityType(),
+ bridge.getPublishedInfo());
+ flushSerialCalls();
+
+ assertTrue(claimed);
+ assertNull(provider.restored);
+ }
+
+ @EdtTest
+ public void aStateFromAnotherDeviceReachesTheListener() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ deliverFromElsewhere("welcome back", 1L);
+
+ assertNotNull(listener.seen);
+ assertEquals("welcome back", listener.seen.getPayload().get("note"));
+ }
+
+ /**
+ * The same state delivered twice acts once. A continuation and a relay routinely carry the
+ * same one.
+ */
+ @EdtTest
+ public void thesameStateDeliveredTwiceActsOnce() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ deliverFromElsewhere("first", 4L);
+ deliverFromElsewhere("first", 4L);
+
+ assertEquals(1, listener.calls);
+ }
+
+ @EdtTest
+ public void aStateOlderThanOneAlreadySeenFromThatDeviceIsIgnored() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ deliverFromElsewhere("newer", 9L);
+ deliverFromElsewhere("older", 2L);
+
+ assertEquals(1, listener.calls);
+ assertEquals("newer", listener.seen.getPayload().get("note"));
+ }
+
+ /**
+ * A listener that returns false has consumed the state: nothing is restored, and no other
+ * listener is asked. This is how an app prompts before moving the user.
+ */
+ @EdtTest
+ public void aListenerThatDeclinesStopsTheRestore() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ return false;
+ }
+ });
+ RecordingListener second = new RecordingListener();
+ Continuity.addContinuationListener(second);
+
+ deliverFromElsewhere("ignored", 1L);
+
+ assertEquals(0, second.calls);
+ assertNull(provider.restored);
+ }
+
+ @EdtTest
+ public void anActivityTypeThisAppNeverPublishedIsNotClaimed() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ boolean claimed = bridge.simulateArrival("com.someone.else.activity",
+ new HashMap());
+ flushSerialCalls();
+
+ assertFalse(claimed);
+ assertEquals(0, listener.calls);
+ }
+
+ @EdtTest
+ public void autoRestoreOffLeavesTheStateForTheApplication() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ deliverFromElsewhere("later", 1L);
+
+ assertNull(provider.restored);
+ AppState waiting = Continuity.getRestorableState();
+ assertNotNull(waiting);
+ assertEquals("later", waiting.getPayload().get("note"));
+ }
+
+ /**
+ * Recording the high-water mark and reaching the event queue are two steps, and two channels
+ * deliver on threads of their own -- so an older state could pass the dedup, pause, and be
+ * queued BEHIND the newer one that overtook it. The event thread then restored the newer
+ * state and overwrote it with the stale one.
+ *
+ * Simulated by delivering the newer state from inside the older one's dispatch window,
+ * which is the same ordering without needing two real threads.
+ */
+ @EdtTest
+ public void aStateSupersededWhileQueuedIsDropped() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ final RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ // Both enqueued before either runs: deliver() records the mark and posts to the EDT, and
+ // nothing here drains the queue in between.
+ Continuity.deliver(fromElsewhere("older", 1L));
+ Continuity.deliver(fromElsewhere("newer", 2L));
+ flushSerialCalls();
+
+ assertEquals(1, listener.calls, "the superseded delivery still ran");
+ assertEquals("newer", listener.seen.getPayload().get("note"));
+ }
+
+ /** An empty document is not a state, so nothing is claimed and no listener runs. */
+ @EdtTest
+ public void anEmptyActivityPayloadIsNotDeliveredToListeners() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ boolean claimed = bridge.simulateArrival(Continuity.getActivityType(),
+ new HashMap());
+ flushSerialCalls();
+
+ assertFalse(claimed, "an activity carrying no state must not be claimed");
+ assertEquals(0, listener.calls);
+ }
+
+ /**
+ * A relay hands back whatever it still holds, which can be days old. Auto-restoring an
+ * expired checkout or booking hold is the exact harm setMaxAge exists to prevent, and the
+ * stored-state check alone never saw this path.
+ */
+ @EdtTest
+ public void anExpiredStateArrivingFromElsewhereIsIgnored() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+ Continuity.setMaxAge(60000L);
+
+ deliverFromElsewhereAged("stale", 1L, System.currentTimeMillis() - 300000L);
+
+ assertEquals(0, listener.calls);
+ }
+
+ /** The same delivery inside the window still arrives, so the check is not simply off. */
+ @EdtTest
+ public void aFreshStateArrivingFromElsewhereStillArrivesWithMaxAgeSet() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+ Continuity.setMaxAge(60000L);
+
+ deliverFromElsewhereAged("fresh", 2L, System.currentTimeMillis());
+
+ assertEquals(1, listener.calls);
+ }
+
+ /**
+ * Dropping an expired state must not consume its sequence, or a fresher state from the same
+ * device would be mistaken for one already seen.
+ */
+ @EdtTest
+ public void anExpiredStateDoesNotConsumeTheSequenceOfAFresherOne() {
+ Continuity.setStateProvider(new RecordingProvider());
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+ Continuity.setMaxAge(60000L);
+
+ deliverFromElsewhereAged("stale", 5L, System.currentTimeMillis() - 300000L);
+ deliverFromElsewhereAged("fresh", 5L, System.currentTimeMillis());
+
+ assertEquals(1, listener.calls);
+ assertEquals("fresh", listener.seen.getPayload().get("note"));
+ }
+
+ /**
+ * A publish REPLACES what the relay holds, so two checkpoints racing to the endpoint could
+ * land in reverse order and leave the user's other device fetching work they had moved past.
+ * Nothing failed and nothing was logged, which is what made it worth pinning.
+ */
+ @EdtTest
+ public void relayPublishesArriveInCheckpointOrder() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ OrderRecordingRelay r = new OrderRecordingRelay();
+ Continuity.setRelay(r);
+
+ for (int i = 1; i <= 6; i++) {
+ provider.saved.put("n", Integer.valueOf(i));
+ Continuity.checkpoint();
+ }
+ final long newest = Continuity.getRestorableState().getSequence();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitPublished(newest);
+ }
+ });
+
+ assertFalse(r.published.isEmpty(), "the relay saw nothing at all");
+ // Coalescing is allowed and expected -- what is not allowed is going backwards.
+ for (int i = 1; i < r.published.size(); i++) {
+ assertTrue(r.published.get(i).longValue() > r.published.get(i - 1).longValue(),
+ "relay saw " + r.published + ", which goes backwards");
+ }
+ assertEquals(Long.valueOf(newest), r.published.get(r.published.size() - 1),
+ "the newest checkpoint has to be the relay's final value");
+ }
+
+ /**
+ * StateRelay.publish documents that a failed state is kept for the next attempt. Dropping it
+ * meant the last checkpoint before the network went away -- the one most worth having --
+ * never reached the other device at all.
+ */
+ @EdtTest
+ public void aFailedPublishKeepsTheStateForTheNextAttempt() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ FailingThenWorkingRelay r = new FailingThenWorkingRelay();
+ Continuity.setRelay(r);
+
+ Continuity.checkpoint();
+ long failed = Continuity.getRestorableState().getSequence();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitAttempts(1);
+ }
+ });
+ assertEquals(0, r.delivered.size(), "the first attempt was supposed to fail");
+
+ // Deliberately NOT another checkpoint. A checkpoint overwrites the pending slot with its
+ // own newer state, so asserting after one proves only that the SECOND state was sent --
+ // which happens whether or not the first was retained. That is what an earlier version of
+ // this test did, and it passed with the retention removed. pollRelay is the reconnect an
+ // application actually makes, and it is what has to send what is owed.
+ //
+ // Polled in a loop rather than once: awaitAttempts returns when publish() is ENTERED, so
+ // the worker may not have finished re-queuing and standing down yet, and a single poll
+ // arriving in that window sees publishing==true and correctly does nothing. A reconnect
+ // that happens twice is what an application does anyway.
+ r.fail = false;
+ long deadline = System.currentTimeMillis() + 3000L;
+ while (r.delivered.isEmpty() && System.currentTimeMillis() < deadline) {
+ // pollRelay() on the EDT, the wait off it: the poll reads event-thread state to
+ // decide whether a fetch is already out, and the completion it is waiting for is a
+ // queued runnable.
+ Continuity.pollRelay();
+ pause(40L);
+ }
+
+ assertEquals(1, r.delivered.size(), "the retained state never reached the relay");
+ assertEquals(Long.valueOf(failed), r.delivered.get(0),
+ "a different state was sent, so the failed one was not the one retained");
+ }
+
+ /**
+ * A fetch that was already on the wire when the user signed out must not be delivered into
+ * the session that follows it. The relay is held inside fetch(), clear() runs while it is
+ * held, and the answer it finally returns belongs to the account that has gone.
+ *
+ * This is the one thing a relay round trip needs that a single-threaded framework cannot
+ * get for free: the request outlives the event-thread turn that started it. It is answered
+ * with a session counter read back on the event thread, not with a lock.
+ */
+ @EdtTest
+ public void aFetchStartedBeforeALogoutIsNotDeliveredAfterIt() {
+ Continuity.enable();
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ final BlockingFetchRelay r = new BlockingFetchRelay();
+ r.answer = foreign("device-x", 3);
+ Continuity.setRelay(r);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitInFlight();
+ }
+ });
+
+ // The user signs out while the fetch is still held.
+ Continuity.clear();
+ r.release();
+ pause(250L);
+
+ assertEquals(0, seen[0],
+ "a state fetched before the logout was delivered into the session after it");
+ }
+
+ /**
+ * An expired parked arrival must not hide a valid local checkpoint. Returning null the moment
+ * the parked state aged out reported "nothing to restore" while storage held a perfectly good
+ * one -- ordinary with automatic restore off and the user still navigating -- so a single
+ * restore() call told the application to show its initial screen instead.
+ */
+ @EdtTest
+ public void anExpiredParkedStateFallsBackToTheStoredCheckpoint() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.enable();
+ // A local checkpoint that is fresh and valid.
+ Continuity.checkpoint();
+ long mine = Continuity.getRestorableState().getSequence();
+
+ // An arrival from elsewhere, parked through the real path: no maxAge yet, so it is
+ // admitted, and automatic restore is off so dispatch parks it rather than applying it.
+ Continuity.setAutoRestore(false);
+ AppState stale = foreign("device-stale", 2);
+ stale.setTimestamp(System.currentTimeMillis() - 5000L);
+ Continuity.deliver(stale);
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ // Now it is too old, which is what an application configuring an expiry would see.
+ Continuity.setMaxAge(1000L);
+
+ AppState offered = Continuity.getRestorableState();
+
+ assertNotNull(offered, "the expired arrival hid the valid stored checkpoint");
+ assertEquals(mine, offered.getSequence(),
+ "the stored checkpoint should be offered once the parked one has expired");
+ }
+
+ /**
+ * A parked state lives only in a field, so a process killed before the application calls
+ * restore() loses it. Persisting the sender's high-water mark at park time therefore left a
+ * durable "already handled" for something nothing ever handled, and the relay's repeat was
+ * rejected on the next launch.
+ */
+ @EdtTest
+ public void parkingAStateDoesNotDurablyMarkItHandled() {
+ Continuity.enable();
+ Continuity.setAutoRestore(false);
+ AppState fromA = foreign("device-parked", 3);
+
+ bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(fromA));
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ assertNotNull(Continuity.getRestorableState(), "the state should be parked for the app");
+
+ // The relaunch: everything in memory goes, storage and preferences stay.
+ Continuity.reset();
+ Continuity.setBridge(bridge);
+ Continuity.enable();
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ Continuity.deliver(fromA);
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+
+ assertEquals(1, seen[0],
+ "the parked state was marked handled durably, so the relay's repeat was rejected "
+ + "and a state nothing ever restored is now unrecoverable");
+ }
+
+ /**
+ * The listener contract documents "do the work yourself and return false". That path never
+ * reaches restore(), so nothing recorded the acknowledgement durably: after a relaunch the
+ * relay's unchanged document was accepted again and the listener repeated its side effects.
+ * acknowledge() is the explicit answer, and it is explicit on purpose -- false also means "I
+ * am going to prompt", and marking THAT handled would lose the state if the process died
+ * before the user answered.
+ */
+ @EdtTest
+ public void acknowledgingAHandledStateSurvivesARestart() {
+ Continuity.enable();
+ final AppState handled = foreign("device-self-handled", 5);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ // Did the work here; nothing to restore.
+ Continuity.acknowledge(state);
+ return false;
+ }
+ });
+
+ Continuity.deliver(handled);
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+
+ // The relaunch.
+ Continuity.reset();
+ Continuity.setBridge(bridge);
+ Continuity.enable();
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return false;
+ }
+ });
+
+ Continuity.deliver(handled);
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+
+ assertEquals(0, seen[0],
+ "an acknowledged state came back after the restart, so the listener's side "
+ + "effects would run a second time");
+ }
+
+ /**
+ * A platform continuation carries nothing but the state, and is delivered on its own merits.
+ * Kept as the positive case beside the rejections above: a guard that refused everything
+ * would pass those and still break the feature.
+ */
+ @EdtTest
+ public void aPlatformArrivalIsDelivered() {
+ Continuity.enable();
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ AppState arrival = foreign("device-platform", 11);
+ Continuity.disable();
+ Continuity.enable();
+
+ Continuity.deliver(arrival);
+ pause(250L);
+
+ assertEquals(1, seen[0], "a platform arrival must be delivered");
+ }
+
+ /**
+ * An off-EDT capture() must report an unrepresentable payload the same way the on-EDT path
+ * does. Marshalled to the EDT, the IllegalArgumentException died in the runnable: the caller
+ * waited out the timeout and got null, so the programming error the exception exists to name
+ * became a silent nothing -- and only when called off the EDT.
+ */
+ @EdtTest
+ public void anInvalidPayloadFailsTheSameWayFromAnyThread() {
+ Continuity.enable();
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ Map bad = new HashMap();
+ bad.put("unsupported", new StringBuilder("not a representable type"));
+ return bad;
+ }
+
+ public void restoreState(Map state) {
+ }
+ });
+
+ // On the EDT, where this test body runs: the refusal is immediate. This half was never
+ // broken, and asserting only it is what made the first version of this test vacuous --
+ // it passed with the exception swallowed, because it never reached the marshalled path.
+ boolean threwOnEdt = false;
+ try {
+ Continuity.capture();
+ } catch (IllegalArgumentException expected) {
+ threwOnEdt = true;
+ }
+ assertTrue(threwOnEdt, "capture() must refuse an unrepresentable payload");
+
+ // And OFF the EDT, which is the path that swallowed it. invokeAndBlock runs this on a
+ // separate thread and releases the EDT to process what capture() marshals to it, so
+ // offEdt() is true here and the call really does go through runOnEdt.
+ final Throwable[] caught = new Throwable[1];
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Continuity.capture();
+ } catch (Throwable t) {
+ caught[0] = t;
+ }
+ }
+ });
+
+ assertTrue(caught[0] instanceof IllegalArgumentException,
+ "an off-EDT capture() reported " + caught[0] + " instead of the "
+ + "IllegalArgumentException the on-EDT path raises for the same payload");
+ }
+
+ /**
+ * An app that only registers a store listener keeps continuity OFF by design -- a key/value
+ * store is not consent to broadcast a route stack. refreshBridge() tested `enabled` alone, so
+ * the simulator's capability menu, which swaps the bridge and calls it, left the replacement
+ * with no callback and every later "Change the Synced Store" silently did nothing.
+ */
+ @EdtTest
+ public void swappingTheBridgeKeepsASyncOnlyListenerWorking() {
+ SyncedStoreListener l = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ registered.add(l);
+ SyncedStore.addChangeListener(l);
+ assertFalse(Continuity.isEnabled(), "a store listener must not turn continuity on");
+
+ // What the simulator's capability menu does.
+ CountingStoreBridge swapped = new CountingStoreBridge();
+ Continuity.setBridge(swapped);
+ Continuity.refreshBridge();
+
+ assertTrue(swapped.callbackInstalls() > 0,
+ "the replacement bridge got no callback, so a change on another device can no "
+ + "longer reach the listener");
+ }
+
+ /**
+ * Every device's mark has to survive a restart, not just one. An earlier shape reconstructed a
+ * single id from the stored checkpoint, so a second foreign device -- or any foreign device
+ * once a local navigation had overwritten the checkpoint -- was delivered and acted on again.
+ */
+ @EdtTest
+ public void everyDevicesHighWaterMarkSurvivesARestart() {
+ Continuity.enable();
+ AppState fromA = foreign("device-a", 4);
+ AppState fromB = foreign("device-b", 9);
+ bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(fromA));
+ bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(fromB));
+ // Drained, because the durable mark is written when a state is ACTED ON and not when it
+ // is admitted -- a process killed between the two would otherwise leave a mark on disk for
+ // a state nothing had handled. Both arrivals dispatch through callSerially and this test
+ // body is the EDT, so without this the states were never acted on and "survives a restart"
+ // would be asserting about something that never happened.
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ // This device then navigates, so the stored checkpoint is OUR state and carries neither id.
+ Continuity.checkpoint();
+
+ Continuity.reset();
+ Continuity.setBridge(bridge);
+ Continuity.enable();
+
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+ Continuity.deliver(fromA);
+ Continuity.deliver(fromB);
+ Display.getInstance().invokeAndBlock(new Runnable() {
+ public void run() {
+ try {
+ Thread.sleep(300);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+
+ assertEquals(0, seen[0],
+ "a device's mark was lost across the restart, so its state was acted on twice");
+ }
+
+ /** A foreign state, ready to deliver. */
+ private static AppState foreign(String device, long sequence) {
+ Map payload = new HashMap();
+ payload.put("k", "v");
+ return new AppState().setPayload(payload).setDeviceId(device).setSequence(sequence)
+ .setTimestamp(System.currentTimeMillis());
+ }
+
+ /**
+ * A reconnect that lands while a publish is in flight has to be honoured. startPublisher()
+ * saw publishing == true and left the work to the live worker -- correct for ordering -- but
+ * if that attempt then FAILED the worker requeued and stood down, forgetting the request. A
+ * single reconnect after a failed send left the retained state unsent until some later
+ * checkpoint happened to restart the publisher.
+ */
+ @EdtTest
+ public void aReconnectDuringAFailedPublishIsRetried() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ BlockingFailingRelay r = new BlockingFailingRelay();
+ Continuity.setRelay(r);
+
+ Continuity.checkpoint();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitInPublish();
+ }
+ });
+ // The application reconnects while the first attempt is still on the wire.
+ Continuity.pollRelay();
+ // Now let that attempt fail; the next one is allowed to succeed.
+ r.fail = false;
+ r.release();
+
+ long deadline = System.currentTimeMillis() + 3000L;
+ while (r.delivered() == 0 && System.currentTimeMillis() < deadline) {
+ pause(25L);
+ }
+
+ assertTrue(r.delivered() > 0,
+ "the reconnect was forgotten, so the retained state was never sent");
+ }
+
+ /** Blocks inside publish() until released, and fails the attempt it was holding. */
+ static class BlockingFailingRelay implements StateRelay {
+ volatile boolean fail = true;
+ private final java.util.concurrent.CountDownLatch gate =
+ new java.util.concurrent.CountDownLatch(1);
+ private final java.util.concurrent.atomic.AtomicInteger inPublish =
+ new java.util.concurrent.atomic.AtomicInteger();
+ private final java.util.concurrent.atomic.AtomicInteger sent =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ public void publish(AppState state) throws java.io.IOException {
+ if (fail) {
+ inPublish.incrementAndGet();
+ try {
+ gate.await(2, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ throw new java.io.IOException("no network");
+ }
+ sent.incrementAndGet();
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+
+ void release() {
+ gate.countDown();
+ }
+
+ int delivered() {
+ return sent.get();
+ }
+
+ void awaitInPublish() {
+ long deadline = System.currentTimeMillis() + 2000L;
+ while (inPublish.get() == 0 && System.currentTimeMillis() < deadline) {
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+ }
+
+ /**
+ * A poll coalesced behind a setRelay() must use the NEW relay. The worker kept the one it was
+ * started with and refreshed only the era, so the second attempt fetched from the endpoint
+ * that had just been replaced and then stamped the answer with the new era -- which made the
+ * era check, whose whole job is to stop exactly that, wave it through.
+ */
+ @EdtTest
+ public void aCoalescedPollUsesTheReplacementRelay() {
+ final BlockingFetchRelay old = new BlockingFetchRelay();
+ Continuity.enable();
+ Continuity.setRelay(old);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ old.awaitInFlight();
+ }
+ });
+
+ // Queued while the old relay's fetch is still held, which is what makes it coalesce.
+ BlockingFetchRelay replacement = new BlockingFetchRelay();
+ replacement.release();
+ Continuity.setRelay(replacement);
+ old.release();
+
+ long deadline = System.currentTimeMillis() + 3000L;
+ while (replacement.fetches() == 0 && System.currentTimeMillis() < deadline) {
+ pause(20L);
+ }
+
+ assertEquals(1, old.fetches(),
+ "the replaced relay was asked a second time, so its answer could still be "
+ + "restored under the new relay's era");
+ assertTrue(replacement.fetches() > 0, "the replacement relay was never asked");
+ }
+
+ /**
+ * A relay holds ONE document per user, so two overlapping GETs can return DIFFERENT states --
+ * the other device may replace it between them. Nothing downstream re-orders the answers:
+ * lastSeen is keyed by the ORIGINATING device, so a response that left first and came back
+ * second passes deduplication on its own key and puts the older screen over the newer one.
+ */
+ @EdtTest
+ public void overlappingPollsNeverRunTwoFetchesAtOnce() {
+ BlockingFetchRelay r = new BlockingFetchRelay();
+ Continuity.enable();
+ Continuity.setRelay(r);
+
+ // Six, and all of them while the first fetch is still held: this is the Android resume
+ // poll landing on top of an application that also polls on reconnect.
+ for (int i = 0; i < 6; i++) {
+ Continuity.pollRelay();
+ }
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitInFlight();
+ }
+ });
+ r.release();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitQuiet();
+ }
+ });
+
+ assertEquals(1, r.maxConcurrent(),
+ "two relay fetches overlapped, so an older response can land after a newer one");
+ // Coalesced, not discarded. A poll asked for while one is in flight is a real question --
+ // the application just reconnected -- and answering it with silence is the lost-request
+ // bug the publisher already had once.
+ assertTrue(r.fetches() >= 2,
+ "the polls requested during the first fetch were dropped rather than coalesced");
+ }
+
+ /**
+ * com.codename1.continuity.sync is a package of its own so that its cost is earned
+ * separately. Enabling the whole framework to register a store listener made every route
+ * change checkpoint -- which on iOS advertises the app's navigation to the devices around it
+ * -- so an application that wanted a key/value store its user's devices share was opted into
+ * broadcasting its route stack. A key/value store is not consent to publish where the user is.
+ */
+ @EdtTest
+ public void registeringAStoreListenerDoesNotEnableContinuity() {
+ CountingStoreBridge counting = new CountingStoreBridge();
+ Continuity.setBridge(counting);
+ SyncedStoreListener l = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ registered.add(l);
+
+ SyncedStore.addChangeListener(l);
+
+ assertFalse(Continuity.isEnabled(),
+ "registering a store listener turned continuity on, so route changes now "
+ + "checkpoint and Handoff advertises them");
+ // The listener still has to be reachable, which is the whole reason the old code enabled.
+ assertTrue(counting.callbackInstalls() > 0,
+ "no callback was installed, so a change on another device could never arrive");
+ }
+
+ /**
+ * A different endpoint is a different destination. A state retained after a failed send was
+ * published to whatever relay replaced the one it was captured for -- an application's data
+ * sent somewhere it was never handed to.
+ */
+ @EdtTest
+ public void replacingTheRelayDropsWorkQueuedForTheOldOne() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ FailingThenWorkingRelay old = new FailingThenWorkingRelay();
+ Continuity.setRelay(old);
+
+ Continuity.checkpoint();
+ long stranded = Continuity.getRestorableState().getSequence();
+ old.awaitAttempts(1);
+ assertEquals(0, old.delivered.size(), "the first attempt was supposed to fail");
+
+ FailingThenWorkingRelay replacement = new FailingThenWorkingRelay();
+ replacement.fail = false;
+ Continuity.setRelay(replacement);
+
+ // Polled the way an application reconnects. Nothing owed to the previous endpoint may
+ // come out of this.
+ long deadline = System.currentTimeMillis() + 1200L;
+ while (System.currentTimeMillis() < deadline) {
+ Continuity.pollRelay();
+ try {
+ Thread.sleep(40);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+
+ assertFalse(replacement.delivered.contains(Long.valueOf(stranded)),
+ "the state captured for the previous relay was published to its replacement");
+ }
+
+ /**
+ * On iOS the external-change observer is installed the first time the platform store is
+ * resolved, and enable() does not resolve it. An application that only registers a listener
+ * and waits to read values inside the callback was therefore never told about a change made
+ * on another device until some unrelated read or write happened to bring the store up.
+ */
+ @EdtTest
+ public void registeringAStoreListenerResolvesThePlatformStore() {
+ CountingStoreBridge counting = new CountingStoreBridge();
+ Continuity.setBridge(counting);
+ SyncedStoreListener l = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ registered.add(l);
+
+ SyncedStore.addChangeListener(l);
+
+ assertTrue(counting.storeQueries() > 0,
+ "registering a listener never reached the platform store, so on iOS no observer "
+ + "would exist and a remote change could not call the listener");
+ }
+
+ /** A LocalContinuityBridge that counts how often the synced store was resolved. */
+ static class CountingStoreBridge extends LocalContinuityBridge {
+ private final java.util.concurrent.atomic.AtomicInteger queries =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ private final java.util.concurrent.atomic.AtomicInteger callbacks =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ @Override
+ public boolean isSyncedStoreSupported() {
+ queries.incrementAndGet();
+ return super.isSyncedStoreSupported();
+ }
+
+ @Override
+ public void setCallback(com.codename1.continuity.spi.ContinuityCallback c) {
+ callbacks.incrementAndGet();
+ super.setCallback(c);
+ }
+
+ int storeQueries() {
+ return queries.get();
+ }
+
+ int callbackInstalls() {
+ return callbacks.get();
+ }
+ }
+
+ /** Holds every fetch until released, and records how many ran at once. */
+ static class BlockingFetchRelay implements StateRelay {
+ /** What fetch() returns once released, or null for "the endpoint has nothing". */
+ volatile AppState answer;
+
+ private final java.util.concurrent.CountDownLatch gate =
+ new java.util.concurrent.CountDownLatch(1);
+ private final java.util.concurrent.atomic.AtomicInteger inFlight =
+ new java.util.concurrent.atomic.AtomicInteger();
+ private final java.util.concurrent.atomic.AtomicInteger peak =
+ new java.util.concurrent.atomic.AtomicInteger();
+ private final java.util.concurrent.atomic.AtomicInteger count =
+ new java.util.concurrent.atomic.AtomicInteger();
+ private final java.util.concurrent.atomic.AtomicInteger posts =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ public void publish(AppState state) {
+ posts.incrementAndGet();
+ }
+
+ /** How many states actually reached the endpoint. */
+ int published() {
+ return posts.get();
+ }
+
+ public AppState fetch() {
+ count.incrementAndGet();
+ int now = inFlight.incrementAndGet();
+ for (;;) {
+ int seen = peak.get();
+ if (now <= seen || peak.compareAndSet(seen, now)) {
+ break;
+ }
+ }
+ try {
+ gate.await(2, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ inFlight.decrementAndGet();
+ return answer;
+ }
+
+ void release() {
+ gate.countDown();
+ }
+
+ int maxConcurrent() {
+ return peak.get();
+ }
+
+ int fetches() {
+ return count.get();
+ }
+
+ /** Waits for the first fetch to actually be inside the relay before releasing it. */
+ void awaitInFlight() {
+ long deadline = System.currentTimeMillis() + 2000L;
+ while (inFlight.get() == 0 && System.currentTimeMillis() < deadline) {
+ sleep();
+ }
+ }
+
+ /** Waits for the coalesced follow-up to run and the worker to stand down. */
+ void awaitQuiet() {
+ long deadline = System.currentTimeMillis() + 3000L;
+ while (System.currentTimeMillis() < deadline) {
+ if (inFlight.get() == 0 && count.get() >= 2) {
+ return;
+ }
+ sleep();
+ }
+ }
+
+ private void sleep() {
+ try {
+ Thread.sleep(20);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /** Fails every publish until `fail` is cleared, and records what got through. */
+ static class FailingThenWorkingRelay implements StateRelay {
+ volatile boolean fail = true;
+ final List delivered =
+ java.util.Collections.synchronizedList(new ArrayList());
+ private final java.util.concurrent.atomic.AtomicInteger attempts =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ public void publish(AppState state) throws java.io.IOException {
+ attempts.incrementAndGet();
+ if (fail) {
+ throw new java.io.IOException("no network");
+ }
+ delivered.add(Long.valueOf(state.getSequence()));
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+
+ void awaitAttempts(int n) {
+ await(new Condition() {
+ public boolean met() {
+ return attempts.get() >= n;
+ }
+ });
+ }
+
+ void awaitDelivered(int n) {
+ await(new Condition() {
+ public boolean met() {
+ return delivered.size() >= n;
+ }
+ });
+ }
+
+ private void await(Condition c) {
+ long deadline = System.currentTimeMillis() + 1500L;
+ while (System.currentTimeMillis() < deadline && !c.met()) {
+ try {
+ Thread.sleep(25);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ interface Condition {
+ boolean met();
+ }
+ }
+
+ /**
+ * disable() documents that arriving states are ignored. A delivery that had already reached
+ * the event queue kept its lastSeen marker and dispatched anyway -- running listeners and
+ * restoring after the application had turned the framework off.
+ */
+ @EdtTest
+ public void aDeliveryQueuedBeforeDisableDoesNotDispatch() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ RecordingListener listener = new RecordingListener();
+ Continuity.addContinuationListener(listener);
+
+ // Queued but not drained: deliver() posts to the event queue and nothing runs it yet.
+ Continuity.deliver(fromElsewhere("after disable", 1L));
+ Continuity.disable();
+ flushSerialCalls();
+
+ assertEquals(0, listener.calls, "a delivery from before disable() still dispatched");
+ }
+
+ /**
+ * disable() documents that checkpoints stop. A publish deferred behind a relay fetch used to
+ * go out anyway: the state sat in the pending slot, disable() left it there, and the fetch
+ * landing afterwards started a publisher that POSTed it -- after disable() had returned.
+ *
+ * The EDT model is what makes this the only shape worth testing. Nothing can interleave
+ * within a turn, so the sole way work outlives the decision is a relay round trip, and this
+ * is that path.
+ */
+ @EdtTest
+ public void aPublishDeferredBehindAFetchIsNotSentAfterDisable() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ final BlockingFetchRelay r = new BlockingFetchRelay();
+ Continuity.setRelay(r);
+ // setRelay polls, so the fetch is in flight and any checkpoint defers behind it.
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitInFlight();
+ }
+ });
+
+ Continuity.checkpoint();
+ Continuity.disable();
+ r.release();
+ pause(500L);
+
+ assertEquals(0, r.published(),
+ "a state queued before disable() was published after it");
+ }
+
+ /**
+ * A payload-only continuation -- what an app that does not use @Route gets -- is APPLIED even
+ * though no form appears, so it must be marked acted-on. The route-less return skipped the
+ * acknowledgement, so the relay's unchanged document was accepted again after a restart and
+ * the listener repeated its side effects.
+ */
+ @EdtTest
+ public void aPayloadOnlyRestoreIsStillMarkedActedOn() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+
+ AppState payloadOnly = fromElsewhere("payload only", 5L);
+ payloadOnly.setRoutes(new ArrayList());
+
+ assertFalse(Continuity.restore(payloadOnly),
+ "a route-less state shows no form, so restore must report false");
+
+ // Re-delivered exactly as a relay would after a restart. The mark has to reject it.
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+ Continuity.deliver(payloadOnly);
+ flushSerialCalls();
+
+ assertEquals(0, seen[0],
+ "a payload-only state that was already applied was delivered a second time");
+ }
+
+ /**
+ * A relay holds ONE document per user, so a GET started while a POST is on the wire can lose
+ * the other device's state outright: the POST replaces it, and the GET then reads back this
+ * device's own echo, which admit() correctly drops. startPublisher() already defers behind an
+ * active fetch for this reason; the poll had no matching guard, so the rule held in one
+ * direction only.
+ */
+ @EdtTest
+ public void aPollDoesNotOverlapAPublishInFlight() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ final GatedRelay r = new GatedRelay();
+ Continuity.setRelay(r);
+
+ // A checkpoint puts a POST on the wire and the relay holds it there.
+ Continuity.checkpoint();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitEntered();
+ }
+ });
+
+ // A DELTA, not an absolute count: setRelay() polls on installation, so a fetch has
+ // already been and gone by the time the publish is on the wire. What must not happen is
+ // another one starting now.
+ final int before = r.fetches();
+
+ // The application reconnects and polls while that POST is still in flight.
+ Continuity.pollRelay();
+ // Given time to happen before it is declared absent. startPoll() spawns a worker, so
+ // reading the count on the very next line races it: the assertion passed whether or not a
+ // fetch had been wrongly started, which is no assertion at all. The positive signal below
+ // is what makes this absence mean something.
+ pause(400L);
+ assertEquals(before, r.fetches(),
+ "a fetch was started while a publish was on the wire, so the POST can overwrite "
+ + "the other device's state before the GET reads it");
+
+ // Released, the deferred poll runs.
+ r.release();
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitFetched(before + 1);
+ }
+ });
+ assertTrue(r.fetches() > before,
+ "the deferred poll was dropped rather than run afterwards");
+ }
+
+ /**
+ * A route-only state whose routes this build no longer registers applies nothing. Writing it
+ * over the stored checkpoint destroyed the user's own restorable position, and acknowledging
+ * it stopped the relay offering it again -- so the next launch found only the unusable state.
+ */
+ @EdtTest
+ public void anUnrestorableStateDoesNotReplaceTheCheckpoint() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+
+ // The user's own checkpoint, which must survive.
+ Continuity.routeStackChanged();
+ Continuity.checkpoint();
+ long mine = Continuity.getRestorableState().getSequence();
+
+ // A foreign state naming a route this build does not have, and carrying no payload.
+ AppState foreignState = new AppState()
+ .setDeviceId("some-other-device")
+ .setSequence(77L)
+ .setTimestamp(System.currentTimeMillis());
+ List unknown = new ArrayList();
+ unknown.add("/a-route-this-build-does-not-register");
+ foreignState.setRoutes(unknown);
+
+ assertFalse(Continuity.restore(foreignState), "an unknown route cannot show a form");
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored, "the local checkpoint was destroyed by a state that applied nothing");
+ assertEquals(mine, stored.getSequence(),
+ "the unusable foreign state replaced the user's own checkpoint");
+ }
+
+ /**
+ * Automatic restoration must not write the durable mark when the restore itself declined to.
+ * admit() has already put the sequence in the live map, so persisting the map behind
+ * commit()'s back marks a state whose checkpoint never stored -- and after a restart enable()
+ * reloads that mark and refuses the relay's only recoverable copy.
+ */
+ @EdtTest
+ public void anAutoRestoreWhoseWriteFailedIsNotDurablyMarked() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+
+ AppState arrival = fromElsewhere("unstorable auto", 31L);
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingStorage());
+ try {
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ // What the next launch would load. The mark must not be there.
+ Map persisted = Continuity.readSeenForTest();
+ assertFalse(persisted.containsKey("some-other-device"),
+ "a state whose checkpoint never stored was durably marked handled, so the relay's "
+ + "only copy is refused after a restart");
+ }
+
+ /**
+ * The high-water map is bounded where entries go IN, not only where they are written out.
+ * Trimming the serialization copy alone left the live map growing for the life of the
+ * process, and made every acknowledgement copy and rescan it -- memory and CPU both climbing
+ * with a relay that supplies many device ids.
+ */
+ @EdtTest
+ public void theLiveHighWaterMapIsBounded() {
+ Continuity.enable();
+
+ // Comfortably past the cap, all from distinct devices.
+ for (int i = 0; i < 200; i++) {
+ AppState s = new AppState()
+ .setDeviceId("device-" + i)
+ .setSequence(i + 1)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.deliver(s);
+ }
+ flushSerialCalls();
+
+ assertTrue(Continuity.seenSizeForTest() <= 64,
+ "the live map grew past its cap: " + Continuity.seenSizeForTest());
+ }
+
+ /**
+ * The marks stay writable when the device ids are long.
+ *
+ * MAX_SEEN bounds the COUNT, and that is not the bound storage imposes. The whole map goes
+ * out as ONE string, written as modified UTF-8 with a length that stops at 65535 bytes, while
+ * a device id is only checked against that limit one at a time on its way into an AppState --
+ * and ids arrive from other devices. A handful of large ones therefore make a combined string
+ * no write can hold.
+ *
+ * The failure is the quiet kind, which is why it is asserted on the PERSISTED map rather
+ * than the live one: this run keeps acknowledging correctly from memory, and nothing reaches
+ * the disk, so after every restart the relay offers an already-applied state again and its
+ * side effects run a second time.
+ */
+ @EdtTest
+ public void longDeviceIdsDoNotStopTheMarksFromBeingStored() {
+ // A provider and a payload, because a mark only becomes durable once the state has been
+ // APPLIED -- commit() writes nothing for a state that restored nothing. A first version
+ // of this test delivered bare states and asserted on a file nothing had written yet, and
+ // it failed identically with SHORT ids, which is what showed the fixture was wrong rather
+ // than the code under test.
+ Continuity.setStateProvider(new RecordingProvider());
+
+ StringBuilder pad = new StringBuilder();
+ for (int i = 0; i < 4000; i++) {
+ pad.append('d');
+ }
+ // Forty of these is roughly 160KB of ids, so the combined string is far past what one
+ // stored string can hold while every id on its own is comfortably legal.
+ String newest = null;
+ for (int i = 0; i < 40; i++) {
+ newest = "device-" + i + "-" + pad;
+ Map payload = new HashMap();
+ payload.put("note", "from " + i);
+ Continuity.deliver(new AppState()
+ .setPayload(payload)
+ .setDeviceId(newest)
+ .setSequence(i + 1)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+ }
+
+ Map persisted = Continuity.readSeenForTest();
+ assertTrue(persisted.containsKey(newest),
+ "the marks could not be written once the ids were long, so the most recent "
+ + "acknowledgement is not durable and that state will be offered again "
+ + "after a restart; persisted=" + persisted.size());
+ }
+
+ /**
+ * A provider that throws is an attempt that FAILED, not an absence of work. It happens
+ * transiently -- a dependency that is not up yet on a cold launch is the ordinary cause --
+ * and marking the state handled with none of its payload applied and nothing stored left the
+ * relay's remaining copy refused after the next launch, so the state was gone for good.
+ *
+ * The distinction matters because "no provider at all" must still acknowledge, or a state
+ * an application can never consume re-prompts for ever. Same flag once, two opposite right
+ * answers, which is why there are two now.
+ */
+ @EdtTest
+ public void aProviderThatThrowsLeavesTheStateOnTheRelay() {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ throw new IllegalStateException("a dependency is not up yet");
+ }
+ });
+
+ AppState arrival = fromElsewhere("provider blew up", 41L);
+ assertFalse(Continuity.restore(arrival), "a route-less state shows no form");
+
+ Map persisted = Continuity.readSeenForTest();
+ assertFalse(persisted.containsKey("some-other-device"),
+ "a state whose provider threw was marked handled, so the relay's remaining copy "
+ + "is refused and none of its payload was ever applied");
+ }
+
+ /**
+ * A brand new device is not the one to evict. Sequences are each origin's own counter, so a
+ * device that has just been set up and sent its first state carries the LOWEST number in the
+ * map -- and evicting by sequence therefore threw that entry out the moment it was admitted.
+ * The dispatch queued behind admit() then found no mark of its own and dropped a perfectly
+ * good continuation with nothing logged.
+ */
+ @EdtTest
+ public void aBrandNewDeviceIsNotEvictedByItsOwnArrival() {
+ Continuity.enable();
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ // Fill the map with established devices, all counting far higher than a new one would.
+ for (int i = 0; i < 64; i++) {
+ AppState old = new AppState()
+ .setDeviceId("established-" + i)
+ .setSequence(5000 + i)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.deliver(old);
+ }
+ flushSerialCalls();
+ seen[0] = 0;
+
+ // A device unboxed this morning, sending its first ever state.
+ AppState firstEver = fromElsewhere("hello from a new phone", 1L);
+ Continuity.deliver(firstEver);
+ flushSerialCalls();
+
+ assertEquals(1, seen[0],
+ "a new device's first state was evicted by its own admission and never dispatched");
+ }
+
+ /**
+ * And the cap is enforced whatever the sequences are. The eviction this replaced scanned for
+ * the lowest value starting from Long.MAX_VALUE, so a map whose values all equalled
+ * Long.MAX_VALUE selected nothing and quietly stopped bounding anything at all.
+ */
+ @EdtTest
+ public void theCapHoldsEvenWhenEverySequenceIsMaxValue() {
+ Continuity.enable();
+ for (int i = 0; i < 80; i++) {
+ AppState s = new AppState()
+ .setDeviceId("maxed-" + i)
+ .setSequence(Long.MAX_VALUE)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.deliver(s);
+ }
+ flushSerialCalls();
+
+ assertTrue(Continuity.seenSizeForTest() <= 64,
+ "the cap stopped being enforced: " + Continuity.seenSizeForTest());
+ }
+
+ /**
+ * A state that was admitted but never completed must not become durable on the back of an
+ * unrelated one. lastSeen holds every admitted state so a run does not dispatch the same
+ * thing twice; serializing that whole map meant a later, successful state carried the failed
+ * one to disk, and after a restart the relay's only usable copy was refused -- undoing, from
+ * the writer, exactly the gating commit() performs.
+ */
+ @EdtTest
+ public void aFailedStateIsNotMadeDurableByAnUnrelatedSuccess() {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ if (payload.containsKey("boom")) {
+ throw new IllegalStateException("cannot apply this one");
+ }
+ }
+ });
+
+ // DELIVERED, not restored directly: admission is what puts the state in the in-memory
+ // dedup map, and that is the precondition -- a state sitting in memory, never completed.
+ // Restoring it by hand skips admit() entirely, so the map never holds it and the test
+ // asserts about a situation that cannot arise.
+ Map boom = new HashMap();
+ boom.put("boom", Boolean.TRUE);
+ AppState failing = new AppState().setPayload(boom).setDeviceId("device-b")
+ .setSequence(7L).setTimestamp(System.currentTimeMillis());
+ Continuity.deliver(failing);
+ flushSerialCalls();
+
+ // A, from another device, then completes normally and writes the marks out.
+ Continuity.deliver(fromElsewhere("fine", 3L));
+ flushSerialCalls();
+
+ Map persisted = Continuity.readSeenForTest();
+ assertTrue(persisted.containsKey("some-other-device"),
+ "the state that completed should have been marked");
+ assertFalse(persisted.containsKey("device-b"),
+ "a state that failed to apply was made durable by an unrelated success, so the "
+ + "relay's only usable copy is refused after a restart");
+ }
+
+ /**
+ * Device ids are not all ours. setDeviceId is public and a state arrives carrying whatever
+ * the relay was given, so an id can contain the characters the persisted format is delimited
+ * by. Unescaped, "phone|work" produced a sequence field that would not parse, and a semicolon
+ * produced a whole second entry -- a mark against an origin that never sent anything, which
+ * then suppresses that origin's real states for good.
+ */
+ @EdtTest
+ public void aDeviceIdContainingTheDelimitersSurvivesARestart() {
+ Continuity.setStateProvider(new RecordingProvider());
+
+ String awkward = "phone|work;other";
+ Map payload = new HashMap();
+ payload.put("note", "hello");
+ Continuity.restore(new AppState().setPayload(payload).setDeviceId(awkward)
+ .setSequence(12L).setTimestamp(System.currentTimeMillis()));
+
+ Map persisted = Continuity.readSeenForTest();
+ assertEquals(Long.valueOf(12L), persisted.get(awkward),
+ "the id did not round-trip through the persisted map: " + persisted.keySet());
+ assertFalse(persisted.containsKey("other"),
+ "the id's semicolon invented a mark for an origin that never sent anything");
+ }
+
+ /**
+ * The continuation label is the previous user's, and clear() is the logout path. Withdrawing
+ * the advertised activity is not enough: the label is a field that outlives it, so the first
+ * checkpoint afterwards -- a login screen, or the next account's opening route -- published
+ * it again to every device around them.
+ */
+ @EdtTest
+ public void logoutForgetsTheContinuationLabel() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setTitle("Invoice 2031 for Dana");
+ Continuity.routeStackChanged();
+ Continuity.checkpoint();
+
+ Continuity.clear();
+
+ assertNull(Continuity.getTitle(), "the previous account's label survived the logout");
+
+ // And the next account's first checkpoint must not carry it either.
+ Continuity.routeStackChanged();
+ Continuity.checkpoint();
+ assertNull(bridge.getPublishedTitle(),
+ "the first checkpoint after logout re-advertised the previous user's label");
+ }
+
+ /**
+ * A payload the provider took is real work even when every route in the same state is stale.
+ * Treating the route failure as fatal discarded it twice: never written to the local
+ * checkpoint, so a cold start lost it, and never acknowledged, so the relay offered the same
+ * half-usable state after every restart -- re-applying the payload and failing the same
+ * routes each time. The routes will not start working on the next launch; the payload
+ * already worked on this one.
+ */
+ @EdtTest
+ public void anAppliedPayloadSurvivesStaleRoutesInTheSameState() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+
+ Map payload = new HashMap();
+ payload.put("note", "the payload applied fine");
+ AppState mixed = new AppState().setPayload(payload).setDeviceId("some-other-device")
+ .setSequence(55L).setTimestamp(System.currentTimeMillis());
+ List stale = new ArrayList();
+ stale.add("/a-route-this-build-does-not-register");
+ mixed.setRoutes(stale);
+
+ assertFalse(Continuity.restore(mixed), "no form can be shown for a stale route");
+
+ assertTrue(provider.restored.containsKey("note"),
+ "the provider should have been given the payload");
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored, "the applied payload never reached the local checkpoint");
+ assertEquals(Long.valueOf(55L), Long.valueOf(stored.getSequence()),
+ "the checkpoint holds a different state than the one that was applied");
+ Map persisted = Continuity.readSeenForTest();
+ assertTrue(persisted.containsKey("some-other-device"),
+ "the state was never acknowledged, so the relay re-offers it after every restart");
+ }
+
+ /**
+ * A state that was fresh when it landed but expired while waiting for the first form must not
+ * be restored. The cold-launch waiter comes back through dispatch() up to WINDOW_WAIT_MILLIS
+ * later, past the check in admit() and the one in getRestorableState() -- so an expired
+ * checkout or booking hold was auto-restored anyway. That check existed before the
+ * event-thread rewrite and the rewrite dropped it.
+ *
+ * Driven through the parked slot and the waiter's own drain, because the wait cannot be
+ * reproduced here: it needs a launch with no form and this harness always has one. An earlier
+ * version of this test delivered the same state twice and asserted nothing at all -- the
+ * second delivery was refused by the in-memory mark long before it could reach dispatch.
+ */
+ @EdtTest
+ public void aStateThatExpiresWhileParkedIsNotRestored() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ // Parked while it was fresh, and older than the limit by the time the waiter drains it.
+ AppState aged = fromElsewhere("stale by the time a form appeared", 61L);
+ aged.setTimestamp(System.currentTimeMillis() - 5000L);
+ Continuity.setMaxAge(1000L);
+ Continuity.parkForTest(aged);
+
+ Continuity.drainParkedForTest();
+ flushSerialCalls();
+
+ assertEquals(0, seen[0],
+ "a state that expired while it was parked was restored anyway");
+ }
+
+ /**
+ * An empty document is a tombstone, not an offer. An enabled app with no routes and no
+ * payload still checkpoints, and the relay holds one document per user, so that empty state
+ * is published to overwrite the stale one -- which is the point. It carries a device id and a
+ * sequence though, so the receiving side ran the listeners over it: a "continue what you were
+ * doing?" prompt about nothing. The platform path already withdrew the activity for an empty
+ * state; only the relay half was missing it.
+ */
+ @EdtTest
+ public void anEmptyArrivalIsConsumedRatherThanOffered() {
+ Continuity.setStateProvider(new RecordingProvider());
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+
+ AppState empty = new AppState().setDeviceId("some-other-device").setSequence(71L)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.deliver(empty);
+ flushSerialCalls();
+
+ assertEquals(0, seen[0],
+ "an empty state was offered to the listeners, prompting the user over nothing");
+ }
+
+ /**
+ * A provider that throws must not replace a stored draft with an empty state. It leaves the
+ * state with no payload, and an app with no routes has nothing else in it -- so the write
+ * destroyed what was safely stored a moment ago, cleared the pending flag so no later suspend
+ * retried, and withdrew the platform continuation, all for a read that may succeed next time.
+ */
+ @EdtTest
+ public void aFailingProviderDoesNotWipeAStoredDraft() {
+ final boolean[] blowUp = new boolean[1];
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ if (blowUp[0]) {
+ throw new IllegalStateException("cannot read the draft right now");
+ }
+ Map m = new HashMap();
+ m.put("draft", "half a sentence");
+ return m;
+ }
+
+ public void restoreState(Map payload) {
+ }
+ });
+
+ Continuity.checkpoint();
+ assertEquals("half a sentence",
+ Continuity.getRestorableState().getPayload().get("draft"),
+ "the draft should have been stored");
+
+ blowUp[0] = true;
+ Continuity.checkpoint();
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored, "the stored draft was destroyed by a provider that threw");
+ assertEquals("half a sentence", stored.getPayload().get("draft"),
+ "an empty state was written over the stored draft");
+ assertTrue(Continuity.isCheckpointPending(),
+ "the failed capture left nothing owed, so no later suspend retries it");
+ }
+
+ /*
+ * There is deliberately NO test here for setBridge(null) handing resolution back to the
+ * platform. The fix covers it -- refreshBridge() resolves override-or-platform and installs
+ * the callback on whichever it picks -- but core-unittests has no platform bridge for
+ * resolution to find, so nothing observable changes in this harness. A test written for it
+ * passed against the BROKEN code too, because the fixture's bridge still held the callback
+ * installed at enable(): it asserted nothing and was removed rather than left looking like
+ * cover. The sibling below exercises the other half of the same condition, and does catch it.
+ */
+
+
+ /**
+ * And a sync-only client gets one too. It installs the inbound seam through
+ * SyncedStore.addChangeListener and deliberately leaves continuity off -- a key/value store
+ * is not consent to broadcast a route stack -- so gating the callback on `enabled` left it
+ * with none, and store changes made on another device never reached its listener.
+ */
+ @EdtTest
+ public void aSyncOnlyClientGetsTheCallbackOnABridgeSwap() {
+ final int[] changes = new int[1];
+ SyncedStoreListener l = new SyncedStoreListener() {
+ public void storeChanged() {
+ changes[0]++;
+ }
+ };
+ registered.add(l);
+ SyncedStore.addChangeListener(l);
+ assertFalse(Continuity.isEnabled(),
+ "registering a store listener must not enable continuity");
+
+ LocalContinuityBridge replacement = new LocalContinuityBridge();
+ Continuity.setBridge(replacement);
+
+ replacement.simulateStoreChange();
+ flushSerialCalls();
+
+ assertEquals(1, changes[0],
+ "a sync-only client got no callback on the replacement bridge");
+ }
+
+ /**
+ * A checkpoint must not overwrite the relay's copy of an arrival the user is still deciding
+ * about. The relay holds one document per user, and a parked state exists ONLY in memory --
+ * so publishing replaces the last copy of it that exists anywhere, and a process death while
+ * the prompt is on screen loses it outright.
+ */
+ @EdtTest
+ public void aCheckpointWaitsWhileAnArrivalIsParked() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ final GatedRelay r = new GatedRelay();
+ Continuity.setRelay(r);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitEntered();
+ }
+ });
+ r.release();
+ pause(300L);
+ final int before = r.sent.size();
+
+ // An arrival the application has been asked about and has not answered.
+ Continuity.deliver(fromElsewhere("waiting on the user", 91L));
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(), "the arrival should be parked");
+
+ Continuity.checkpoint();
+ pause(300L);
+ assertEquals(before, r.sent.size(),
+ "a checkpoint overwrote the relay's only copy of a state the user was still "
+ + "being asked about");
+
+ // Answering releases it -- held, not dropped.
+ //
+ // acknowledge(), not restore(). Both are decisions, and only one of them leaves the held
+ // checkpoint still true: restoring REPLACES the screen it describes, so sending it
+ // afterwards would put the superseded work over the relay's copy of the state just
+ // accepted, and it is dropped on purpose --
+ // aCheckpointQueuedBeforeARestoreIsNotPublishedAfterIt covers that. Acknowledging changes
+ // nothing on screen, so the work captured while the user was being asked is still what
+ // this device is doing, and it has to go out or the hold is a place things vanish into.
+ Continuity.acknowledge(Continuity.getRestorableState());
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitAnySince(before);
+ }
+ });
+ assertTrue(r.sent.size() > before,
+ "the held publication was dropped rather than sent once the decision was made");
+ }
+
+ /**
+ * A mark that never reached storage must not be reported as durable. Preferences cannot say
+ * whether a write landed -- set() fills a static table and save() discards
+ * Storage.writeObject()'s result -- so these values moved to Storage, which can.
+ */
+ @EdtTest
+ public void marksThatCannotBeStoredAreNotSilentlyClaimed() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingStorage());
+ try {
+ Continuity.acknowledge(fromElsewhere("cannot be stored", 95L));
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ Map persisted = Continuity.readSeenForTest();
+ assertFalse(persisted.containsKey("some-other-device"),
+ "a mark that never reached storage was reported as durable");
+ }
+
+ /**
+ * A checkpoint whose write failed is still owed. `dirty` is cleared on the way in, so leaving
+ * it clear told the next suspend there was nothing to save -- a checkpoint lost to a full
+ * disk was never retried and the app came back to the last write that had succeeded.
+ */
+ @EdtTest
+ public void aFailedCheckpointWriteLeavesTheStateOwed() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingStorage());
+ try {
+ Continuity.routeStackChanged();
+ Continuity.checkpoint();
+ assertTrue(Continuity.isCheckpointPending(),
+ "a checkpoint whose write failed was reported as saved");
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+ }
+
+ /**
+ * And a restore whose write failed must not acknowledge the state. noteActedOn() is durable
+ * and stops the relay ever offering it again, so doing it on top of a failed write loses the
+ * state in both directions at once -- nothing stored here, nothing left to fetch.
+ */
+ @EdtTest
+ public void aFailedRestoreWriteDoesNotAcknowledgeTheState() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+
+ AppState arrival = fromElsewhere("unstorable", 21L);
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingStorage());
+ try {
+ Continuity.restore(arrival);
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ // The relay would offer it again. It has to be accepted, not refused as already handled.
+ final int[] seen = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ seen[0]++;
+ return true;
+ }
+ });
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+
+ assertEquals(1, seen[0],
+ "a state whose write failed was marked handled, so it can never be recovered");
+ }
+
+ /**
+ * A sequence that could not be stored must not be handed to another device. The receiver
+ * records it durably, this device hands the same number out again after a restart, and every
+ * checkpoint it then sends is refused as already seen until the counter climbs past it.
+ *
+ * The local write still happens: this device does not deduplicate against itself, so the
+ * stored checkpoint is worth having. Only the PUBLISHING is harmful, which is why the payload
+ * failure beside it -- where publishing is fine -- cannot share the same flag.
+ */
+ @EdtTest
+ public void aSequenceThatCannotBeStoredIsNotPublished() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ final GatedRelay r = new GatedRelay();
+ Continuity.setRelay(r);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitEntered();
+ }
+ });
+ r.release();
+ pause(300L);
+ final int before = r.sent.size();
+ bridge.clearContinuation();
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEQUENCE));
+ try {
+ // NOT routeStackChanged(): that only schedules a flush, and the queued checkpoint
+ // then ran after the finally below had put the real storage back -- so the test was
+ // watching a second, perfectly successful checkpoint publish and calling it a
+ // failure of the first.
+ Continuity.checkpoint();
+ flushSerialCalls();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+ pause(300L);
+
+ assertEquals(before, r.sent.size(),
+ "a sequence that never reached storage was published to the relay, so the "
+ + "receiver's durable mark will outlive the counter that produced it");
+ assertNull(bridge.getPublishedType(),
+ "the same sequence was advertised over the platform continuation");
+ assertTrue(Continuity.isCheckpointPending(),
+ "the checkpoint was reported as done even though the counter is not durable");
+ }
+
+ /**
+ * The public capture() is documented for feeding the application's own transport, so it is a
+ * publisher too -- and a state whose sequence never reached storage is exactly what must not
+ * be published. The caller cannot tell: nothing on AppState says whether its number is one
+ * this device will issue again after a restart.
+ *
+ * The control half matters more than the failing half here. Returning null unconditionally
+ * would satisfy the assertion below while breaking the method, so the same call is made with
+ * working storage first and required to produce a state.
+ */
+ @EdtTest
+ public void aCaptureWhoseSequenceCannotBeStoredIsRefused() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+
+ AppState healthy = Continuity.capture();
+ assertNotNull(healthy,
+ "capture() returned nothing with storage working, so the refusal below proves "
+ + "nothing about the sequence");
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEQUENCE));
+ AppState refused;
+ try {
+ refused = Continuity.capture();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ assertNull(refused,
+ "capture() handed out a state whose sequence never reached storage, and the "
+ + "caller publishes it -- so the receiver's durable mark outlives the "
+ + "counter that produced it and later states are silently ignored");
+ }
+
+ /**
+ * A poll that brings back a state nobody has dealt with yet must not release a queued publish.
+ *
+ * The publisher's hold on a parked arrival exists because a parked state's only copy is on
+ * the relay, and publishing replaces that single document. admit() deliberately queues the
+ * dispatch for a LATER turn -- that second turn is what lets an older state notice it was
+ * superseded -- so when the poll finished in the same turn, {@code parked} was still null and
+ * the hold had nothing to see. The worker it started never looks again.
+ */
+ @EdtTest
+ public void aFetchedStateNobodyHasHandledYetHoldsTheQueuedPublish() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ // Nothing applies the arrival, so it parks -- the state the hold is for.
+ Continuity.setAutoRestore(false);
+
+ final AppState waiting = fromElsewhere("fetched, unhandled", 91L);
+ final java.util.concurrent.CountDownLatch inFetch =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch release =
+ new java.util.concurrent.CountDownLatch(1);
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() {
+ if (!served.getAndSet(false)) {
+ return null;
+ }
+ // Held open so the checkpoint below is queued WHILE the poll is running, which is
+ // the situation the finding is about: work owed to the relay, and a state coming
+ // back that nobody has looked at yet.
+ inFetch.countDown();
+ try {
+ release.await(2L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ return waiting;
+ }
+ });
+
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ try {
+ inFetch.await(2L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ // Owed while the poll is in flight, so pollFinished() is what releases it.
+ Continuity.checkpoint();
+ release.countDown();
+
+ // Two turns: the one pollFinished() runs in, and the one it queues the dispatch into.
+ pause(400L);
+ flushSerialCalls();
+ pause(200L);
+ flushSerialCalls();
+
+ assertNotNull(Continuity.getRestorableState(),
+ "the fetched state never parked, so this test is not about the hold at all");
+ assertTrue(published.isEmpty(),
+ "a checkpoint was published over the relay's only copy of a state the "
+ + "application has not dealt with yet: published=" + published.size());
+
+ // And it was genuinely HELD, not simply never owed. Acknowledging the arrival is what
+ // releases the hold, so a publish arriving now is the proof that one was waiting -- and
+ // without it the assertion above would pass just as well on a relay nothing ever wanted
+ // to write to, which is how the first version of this test proved nothing.
+ Continuity.acknowledge(waiting);
+ pause(300L);
+ flushSerialCalls();
+ assertFalse(published.isEmpty(),
+ "no publish followed the acknowledgement, so nothing was ever owed to the relay "
+ + "and the empty check above was vacuous");
+ }
+
+ /** What the fetch above hands over, once. */
+ private final java.util.concurrent.atomic.AtomicBoolean served =
+ new java.util.concurrent.atomic.AtomicBoolean(true);
+
+ /** What that relay was asked to publish. */
+ private final List published =
+ java.util.Collections.synchronizedList(new ArrayList());
+
+ /**
+ * Logout has to leave nothing restorable even when the port refuses to delete the file.
+ *
+ * {@code deleteStorageFile} returns void and the ports behind it discard the answer they do
+ * get -- JavaSE ignores {@code File.delete()}'s boolean, Android ignores
+ * {@code Context.deleteFile()}'s -- so a refused deletion is invisible and leaves the
+ * signed-out account's routes and payload on disk, ready to be restored into the next
+ * login.
+ */
+ @EdtTest
+ public void aLogoutLeavesNothingRestorableEvenWhenTheDeleteIsRefused() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("secret", "the previous account's work");
+ Continuity.setStateProvider(provider);
+ Continuity.checkpoint();
+ assertNotNull(Continuity.getRestorableState(),
+ "there is no checkpoint to lose, so the assertion below would pass on nothing");
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new UndeletableStorage(original));
+ try {
+ Continuity.clear();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ assertNull(Continuity.getRestorableState(),
+ "logout left the previous account's checkpoint on disk, so a restart restores "
+ + "one account's routes and payload into the next account's session");
+ }
+
+ /** Storage whose deleteStorageFile does nothing at all, silently, as a refusing port does. */
+ static class UndeletableStorage extends Storage {
+ private final Storage delegate;
+
+ UndeletableStorage(Storage delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ // Deliberately nothing. This is the port behaviour under test: the entry survives and
+ // the caller is told nothing, because the method cannot tell it anything.
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ return delegate.writeObject(name, o);
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(name);
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(name);
+ }
+ }
+
+ /**
+ * A relay that is no longer installed refuses on the credential path, before it reads a token.
+ *
+ * The publish worker confirms its session on the event thread before calling a relay, and
+ * that leaves one gap: the worker is a different thread, so between the confirmation and the
+ * relay reading its token a logout and a login can both have happened.
+ * {@code RestStateRelay.getToken()} is read at each request by design, so an object kept
+ * across both would answer with whoever is signed in NOW -- and the previous account's state
+ * would go out under the next account's credentials.
+ *
+ * What closes it is installing the new account's relay, which is the documented way to
+ * change accounts: the replaced object is refused for good. An application that instead keeps
+ * one relay and swaps the token inside it is beyond what any framework check can see, and
+ * getToken() says so.
+ *
+ * No server is involved: the refusal is the first thing {@code auth} does, so reaching the
+ * network at all would be the failure.
+ */
+ @EdtTest
+ public void aRelayThatIsNoLongerInstalledRefusesBeforeReadingItsToken() {
+ final boolean[] tokenRead = new boolean[1];
+ RestStateRelay relay = new RestStateRelay("https://example.invalid/continuity") {
+ @Override
+ protected String getToken() {
+ tokenRead[0] = true;
+ return "the-next-account-token";
+ }
+ };
+
+ Continuity.setRelay(relay);
+ // The control: while it IS installed the guard must not fire, or the assertion below
+ // would pass against a relay that simply never works.
+ assertTrue(Continuity.isInstalledRelay(relay),
+ "the relay was not installed, so the refusal below proves nothing");
+
+ // setRelay() starts a poll, and THAT read is entitled to a token -- the relay is
+ // installed. Let it finish and forget it, because the property under test is what happens
+ // after the relay is replaced. Without this the test raced its own fixture: the worker
+ // sometimes reached getToken() before the assertion and sometimes did not, so it passed
+ // for a timing reason rather than a behavioural one.
+ pause(300L);
+ flushSerialCalls();
+ tokenRead[0] = false;
+
+ // REPLACED, not cleared. clear() is a logout and deliberately keeps the relay installed:
+ // the same endpoint usually serves the next account. What the framework can recognise is
+ // a relay that is no longer the installed one, which is why switching accounts means
+ // installing the new account's relay rather than swapping a token inside the old object.
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ });
+
+ try {
+ relay.publish(new AppState().setDeviceId("previous-account").setSequence(1L));
+ fail("a relay that setRelay() replaced must not send anything");
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().contains("may not send"),
+ expected.getMessage());
+ }
+ assertFalse(tokenRead[0],
+ "the token was read before the relay noticed it had been removed, which is the "
+ + "credential the previous account's state would have gone out under");
+ }
+
+ /**
+ * A restore that FAILED keeps the parked state, and keeps the publication held with it.
+ *
+ * restore(AppState) deliberately does not acknowledge a failed attempt -- a provider that
+ * throws is usually transient, a dependency not up yet on a cold launch -- so the state stays
+ * on the relay for a launch that can use it. The no-argument restore() cleared the slot
+ * anyway, and both halves of that hurt: admit() has already put the sequence in the live map,
+ * so nothing offers the state again this run, and releasing the hold lets a checkpoint
+ * overwrite the relay's only copy. The retry it was being kept for has nothing left to
+ * retry.
+ */
+ @EdtTest
+ public void aFailedRestoreKeepsTheParkedState() {
+ Continuity.setAutoRestore(false);
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ throw new IllegalStateException("the dependency this needs is not up yet");
+ }
+ });
+
+ AppState arrival = fromElsewhere("work worth keeping", 41L);
+ arrival.setRoutes(new ArrayList());
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(),
+ "the arrival never parked, so there is no slot for the restore to lose");
+
+ assertFalse(Continuity.restore(), "a provider that threw cannot have shown anything");
+
+ assertNotNull(Continuity.getRestorableState(),
+ "a restore that failed threw away the only copy it was keeping: nothing offers "
+ + "the state again this run and the relay's copy is now replaceable");
+ }
+
+ /**
+ * And the control: a restore that WORKED still releases the slot.
+ *
+ * Without this the fix above is satisfied by never clearing at all, which would keep a
+ * handled arrival on offer for ever and hold every later checkpoint behind it.
+ */
+ @EdtTest
+ public void aSuccessfulRestoreStillReleasesTheParkedState() {
+ Continuity.setAutoRestore(false);
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ final List out = java.util.Collections.synchronizedList(new ArrayList());
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ out.add(state);
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ });
+ pause(200L);
+ flushSerialCalls();
+ out.clear();
+
+ AppState arrival = fromElsewhere("work that applies", 42L);
+ arrival.setRoutes(new ArrayList());
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+
+ // Asserted on the HOLD, not on getRestorableState(). A successful restore persists the
+ // state, so that method legitimately keeps answering afterwards -- with the stored
+ // checkpoint rather than the parked arrival -- and a first version of this test read that
+ // as the slot never being released.
+ Continuity.checkpoint();
+ pause(200L);
+ flushSerialCalls();
+ assertTrue(out.isEmpty(), "the parked arrival did not hold the checkpoint back at all");
+
+ Continuity.restore();
+ pause(300L);
+ flushSerialCalls();
+
+ // NOT the checkpoint captured before the restore: that one described the screen the
+ // restore replaced, and sending it would overwrite the relay's copy of the state just
+ // accepted. It is dropped on purpose. What has to work is the NEXT one -- if the hold
+ // had never been released, this would be held too and the arrival would keep every
+ // future checkpoint off the relay for good.
+ assertTrue(out.isEmpty(), "the stale pre-restore checkpoint was published after all");
+ provider.saved.put("after", "work done since the restore");
+ Continuity.checkpoint();
+ pause(300L);
+ flushSerialCalls();
+
+ assertFalse(out.isEmpty(),
+ "a checkpoint made after the restore was still held, so the arrival keeps every "
+ + "later checkpoint off the relay for good");
+ }
+
+ /**
+ * A listener that defers an arrival keeps it parked.
+ *
+ * False has two documented meanings: "I did the work myself" and "keep it, I will prompt
+ * and call restore() when the user accepts". The second is a state waiting on a human whose
+ * only other copy is the relay's, and returning without the slot left no hold at all -- so a
+ * queued checkpoint could replace that copy while the prompt was still on screen, and a
+ * process death before the answer lost the work.
+ */
+ @EdtTest
+ public void aListenerThatDefersAnArrivalKeepsItParked() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ // "Keep it, I am prompting" -- the documented deferral.
+ return false;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("waiting on the user", 43L));
+ flushSerialCalls();
+
+ AppState offered = Continuity.getRestorableState();
+ assertNotNull(offered,
+ "a deferred arrival was dropped, so nothing holds a checkpoint off the relay's "
+ + "only copy while the user is being asked about it");
+ assertEquals(43L, offered.getSequence(), "a different state was left on offer");
+
+ // And the hold really ends when the application says so, rather than never.
+ Continuity.acknowledge(offered);
+ assertNull(Continuity.getRestorableState(),
+ "acknowledge() did not release the deferred state, which would hold every later "
+ + "checkpoint behind an arrival the application has finished with");
+ }
+
+ /**
+ * A tagged boolean the encoder never wrote keeps its text instead of becoming false.
+ *
+ * Boolean.valueOf answers false for every string that is not "true", so "b:unknown"
+ * arrived as a confident false -- application data changed in transit, restored, and
+ * acknowledged, with nothing said. Every other tag in this codec already preserves a body it
+ * cannot parse; this one did not.
+ */
+ @EdtTest
+ public void aTaggedBooleanThatWillNotParseIsNotSilentlyFalse() throws Exception {
+ AppState real = StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"3\",\"enc\":\"1\","
+ + "\"payload\":{\"on\":\"b:true\",\"off\":\"b:false\"}}");
+ assertEquals(Boolean.TRUE, real.getPayload().get("on"),
+ "the encoder's own true did not survive the round trip");
+ assertEquals(Boolean.FALSE, real.getPayload().get("off"),
+ "the encoder's own false did not survive the round trip");
+
+ AppState odd = StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"3\",\"enc\":\"1\","
+ + "\"payload\":{\"enabled\":\"b:unknown\"}}");
+ Object kept = odd.getPayload().get("enabled");
+ assertEquals("b:unknown", kept,
+ "a boolean body this codec cannot read became " + kept + " instead of keeping "
+ + "its text, so the application is handed a value the sender never sent "
+ + "-- and the state is acknowledged, so the sender never learns of it");
+ }
+
+ /**
+ * A logout refuses a worker that is already inside the relay, even though the relay stays
+ * installed.
+ *
+ * This is the half the identity check could not see. {@code setRelay()} swaps the object,
+ * so a replaced relay was caught -- but {@code clear()} deliberately leaves the SAME relay in
+ * place, because the same endpoint usually serves the next account. A worker whose preflight
+ * passed a moment before the logout therefore found its relay still installed and sent the
+ * previous account's state anyway. With cookie or client-certificate authentication there is
+ * not even a token for getToken() to have stopped returning.
+ *
+ * Asked at the point {@code RestStateRelay.auth()} asks it, on the worker, inside the
+ * relay call -- which is the only place the window exists. The first answer is asserted too:
+ * a guard that refused every worker would pass the second half of this while breaking every
+ * ordinary publish.
+ */
+ @EdtTest
+ public void aLogoutRefusesAWorkerAlreadyInsideTheRelay() {
+ final java.util.concurrent.atomic.AtomicInteger asked =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicBoolean beforeLogout =
+ new java.util.concurrent.atomic.AtomicBoolean();
+ final java.util.concurrent.atomic.AtomicBoolean afterLogout =
+ new java.util.concurrent.atomic.AtomicBoolean(true);
+ final java.util.concurrent.CountDownLatch inPublish =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch loggedOut =
+ new java.util.concurrent.CountDownLatch(1);
+ final StateRelay[] self = new StateRelay[1];
+
+ StateRelay relay = new StateRelay() {
+ public void publish(AppState state) {
+ // Exactly what RestStateRelay.auth() does, in the same place: on the worker,
+ // inside the relay call, immediately before the credentials would be read.
+ beforeLogout.set(Continuity.mayRelaySend(self[0]));
+ asked.incrementAndGet();
+ inPublish.countDown();
+ try {
+ loggedOut.await(5, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ afterLogout.set(Continuity.mayRelaySend(self[0]));
+ asked.incrementAndGet();
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ };
+ self[0] = relay;
+
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ Continuity.setRelay(relay);
+ Continuity.checkpoint();
+
+ // Pumped rather than blocked: the worker calls back onto the event thread to read the
+ // session, and this test IS the event thread. Blocking here would deadlock the very
+ // mechanism under test.
+ for (int i = 0; i < 40 && inPublish.getCount() > 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(0L, inPublish.getCount(),
+ "the publish worker never reached the relay, so this test asserts nothing");
+
+ Continuity.clear();
+ loggedOut.countDown();
+ for (int i = 0; i < 40 && asked.get() < 2; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(2, asked.get(), "the worker never asked again after the logout");
+
+ assertTrue(beforeLogout.get(),
+ "an ordinary worker was refused before any logout, which would stop every "
+ + "publish this framework makes");
+ assertFalse(afterLogout.get(),
+ "clear() left the relay installed, so the worker was told it could still send -- "
+ + "and the previous account's state goes out after the logout that "
+ + "promised nothing would");
+ }
+
+ /**
+ * A sequence past the range of a long is a failed read, and an ordinary one still is not.
+ *
+ * The pair matters: a guard that refused every numeric sequence would pass the first half
+ * of this and silently drop every sender that writes seq as a number rather than a string,
+ * which this codec has always accepted and still must.
+ */
+ @EdtTest
+ public void anOutOfRangeSequenceIsRefusedAndAnOrdinaryOneIsNot() throws Exception {
+ AppState fine = StateCodec.fromJson("{\"device\":\"other\",\"seq\":10,\"ts\":99}");
+ assertNotNull(fine, "a sender writing seq as a plain number was refused");
+ assertEquals(10L, fine.getSequence(), "the numeric sequence did not survive");
+ assertEquals(99L, fine.getTimestamp(), "the numeric timestamp did not survive");
+
+ // The largest sequence a long holds is itself in range and must go through: the guard is
+ // about values OUTSIDE the type, not about large ones.
+ AppState edge = StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"" + Long.MAX_VALUE + "\"}");
+ assertEquals(Long.MAX_VALUE, edge.getSequence(),
+ "a sequence written as the largest long there is was not preserved");
+
+ try {
+ StateCodec.fromJson("{\"device\":\"other\",\"seq\":9223372036854775808}");
+ fail("2^63 was accepted as a sequence. It is the same double as (double) "
+ + "Long.MAX_VALUE, so a range test written against that constant compares "
+ + "equal and clamps it back to Long.MAX_VALUE -- the exact poisoning the "
+ + "1e100 guard was added to stop, one value past where it looks.");
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ try {
+ StateCodec.fromJson("{\"device\":\"other\",\"seq\":1e100}");
+ fail("1e100 was accepted as a sequence -- clamped to Long.MAX_VALUE, it becomes this "
+ + "origin's durable high-water mark and refuses everything it sends later");
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+ }
+
+ /**
+ * A route array whose elements are not strings fails the fetch instead of arriving empty.
+ *
+ * This is the whole chain, not just the codec: {@code {"routes":[1]}} passed the outer
+ * List check, the reader dropped the element it could not use, and what came back was an
+ * AppState with no routes and no payload. That is precisely a tombstone -- the shape this
+ * framework reads as "the origin cleared its work" -- so a document that merely had one bad
+ * element was consumed as an instruction to drop work, and it was marked durably so the
+ * correction could never be re-read.
+ *
+ * The observable here is that durable mark. A refused fetch is an IOException the poll
+ * reports, and nothing about the sender is remembered; an admitted tombstone always records
+ * one, which is what {@code aConsumedTombstoneIsMarkedDurably} pins down.
+ */
+ @EdtTest
+ public void aFetchWithANonStringRouteIsNotConsumedAsATombstone() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+
+ // What the OLD code produced from this document, spelled out so the harm is not taken on
+ // trust: an empty state, which isEmpty() -- and therefore admission -- reads as a
+ // tombstone.
+ AppState routeless = new AppState()
+ .setDeviceId("bad-element-sender")
+ .setSequence(10L)
+ .setTimestamp(System.currentTimeMillis());
+ assertTrue(routeless.isEmpty(),
+ "an AppState with no routes and no payload is not empty here, so the tombstone "
+ + "consequence this test is about does not exist");
+
+ final String[] doc = {
+ "{\"device\":\"bad-element-sender\",\"seq\":\"10\",\"routes\":[1]}"
+ };
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() throws java.io.IOException {
+ // Exactly what RestStateRelay does with the body it received.
+ return StateCodec.fromJson(doc[0]);
+ }
+ });
+ // setRelay() polls on a background thread, so a flush alone proves nothing: the first
+ // version of this test asserted before the fetch had run and passed against the unfixed
+ // code. Wait for the poll, then drain what it queued.
+ pause(300L);
+ flushSerialCalls();
+
+ assertNull(Continuity.readSeenForTest().get("bad-element-sender"),
+ "a document with a non-string route was admitted and marked durably, so a "
+ + "malformed element was consumed as an instruction to drop work -- and "
+ + "the mark means the sender's correction is refused as already seen");
+ }
+
+ /**
+ * A consumed tombstone is marked durably.
+ *
+ * It is the one arrival that cannot fail -- no payload to hand over, no route to rebuild --
+ * so there is nothing to gate the mark on. Recording it in memory only meant the next launch
+ * had never heard of it, and an older state from the same origin that was already in flight
+ * passed admission and offered work the tombstone exists to say no longer exists.
+ */
+ @EdtTest
+ public void aConsumedTombstoneIsMarkedDurably() {
+ Continuity.setStateProvider(new RecordingProvider());
+
+ AppState tombstone = new AppState()
+ .setDeviceId("some-other-device")
+ .setSequence(77L)
+ .setTimestamp(System.currentTimeMillis());
+ assertTrue(tombstone.isEmpty(), "this is not a tombstone, so the test is about nothing");
+ Continuity.deliver(tombstone);
+ flushSerialCalls();
+
+ Map persisted = Continuity.readSeenForTest();
+ Long mark = persisted.get("some-other-device");
+ assertNotNull(mark,
+ "the tombstone was consumed without a durable mark, so after a restart an older "
+ + "state still in flight from that origin resurrects the work it cleared");
+ assertEquals(77L, mark.longValue(), "the durable mark is not the tombstone's sequence");
+ }
+
+ /**
+ * A listener that acknowledges inside stateReceived and returns false leaves nothing parked.
+ *
+ * That is the documented handle-it-yourself pattern, and it does both things: the
+ * acknowledgement runs first, while there is nothing parked for it to release, so parking
+ * afterwards left a finished arrival on offer for the rest of the process with every relay
+ * checkpoint held behind it -- the hold applied to work that was already done.
+ */
+ @EdtTest
+ public void anArrivalAcknowledgedInsideTheListenerIsNotParked() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ Continuity.acknowledge(state);
+ return false;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("handled in the callback", 51L));
+ flushSerialCalls();
+
+ assertNull(Continuity.getRestorableState(),
+ "an arrival the listener had already acknowledged was parked anyway, so it stays "
+ + "on offer and holds every later checkpoint off the relay");
+ }
+
+ /**
+ * A checkpoint queued before a restore is not sent afterwards.
+ *
+ * A navigation while a relay GET is in flight leaves that checkpoint in the slot. If the
+ * GET brings back a state that is restored, the queued one describes a screen that no longer
+ * exists -- and sending it replaces the relay's copy of the state just accepted with the work
+ * the restore superseded.
+ */
+ @EdtTest
+ public void aCheckpointQueuedBeforeARestoreIsNotPublishedAfterIt() {
+ final RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("screen", "one");
+ Continuity.setStateProvider(provider);
+
+ // A relay whose FIRST publish blocks. That is what leaves a second checkpoint sitting in
+ // the slot, which is the state this test is about -- and it is deterministic, unlike
+ // racing a relay GET: an earlier version timed the fetch and passed alone while failing
+ // in the suite, because the window it needed was never actually open.
+ final java.util.concurrent.CountDownLatch inPublish =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch release =
+ new java.util.concurrent.CountDownLatch(1);
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ if (inPublish.getCount() > 0) {
+ inPublish.countDown();
+ try {
+ release.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ });
+
+ Continuity.checkpoint();
+ final boolean[] blocked = new boolean[1];
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ try {
+ blocked[0] = inPublish.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ assertTrue(blocked[0], "no publish is in flight, so nothing would queue behind it");
+
+ // Queued behind the worker: this is the checkpoint a restore is about to make stale.
+ provider.saved.put("stale", Boolean.TRUE);
+ Continuity.checkpoint();
+
+ // And the restore that supersedes it.
+ Continuity.deliver(fromElsewhere("what the other device was doing", 52L));
+ flushSerialCalls();
+ assertTrue(provider.restored != null && provider.restored.containsKey("note"),
+ "the arrival was not applied, so nothing superseded the queued checkpoint and "
+ + "this test is about nothing");
+
+ release.countDown();
+ pause(400L);
+ flushSerialCalls();
+
+ boolean staleWentOut = false;
+ for (AppState sent : published) {
+ if (sent.getPayload().containsKey("stale")) {
+ staleWentOut = true;
+ }
+ }
+ assertFalse(staleWentOut,
+ "the checkpoint queued before the restore was published over the relay's copy of "
+ + "the state that restore had just accepted");
+ }
+
+ /**
+ * Logout forgets the route history, not only the stored checkpoint.
+ *
+ * A route stack is the previous account's work as surely as a checkpoint is. Leaving it
+ * kept two promises broken: back() reopened the signed-out account's forms, and the next
+ * navigation checkpointed and republished a stack that still began with their routes.
+ */
+ @EdtTest
+ public void logoutForgetsTheRouteHistory() {
+ Continuity.setStateProvider(new RecordingProvider());
+ // Restored in the finally, because the dispatcher is global: a first version left one
+ // installed that answered EVERY path with a form, and the tests that run after it -- the
+ // ones about routes this build does not register -- then found every stale route
+ // perfectly dispatchable and failed on an assertion that had nothing to do with them.
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String path) {
+ return new Form(path);
+ }
+ });
+ try {
+ Navigation.navigate("/account/statement");
+ flushSerialCalls();
+ assertFalse(Navigation.getStack().isEmpty(),
+ "nothing was navigated, so there is no history for logout to forget");
+
+ Continuity.clear();
+
+ assertTrue(Navigation.getStack().isEmpty(),
+ "logout left the signed-out account's route history in place, so back() "
+ + "reopens their forms and the next navigation republishes them");
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * An automatic restore that FAILED leaves the arrival parked.
+ *
+ * pollFinished() has already queued a publisher behind this dispatch, so with the slot
+ * empty it posts the pending local checkpoint over the relay's only copy of the state that
+ * just failed -- and the retry the failure is kept for has nothing left to retry. The
+ * deferred-listener branch beside it got this; the automatic one threw the answer away.
+ */
+ @EdtTest
+ public void anAutomaticRestoreThatFailedKeepsTheArrivalParked() {
+ Continuity.setAutoRestore(true);
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ throw new IllegalStateException("the dependency this needs is not up yet");
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("work worth keeping", 61L));
+ flushSerialCalls();
+
+ AppState offered = Continuity.getRestorableState();
+ assertNotNull(offered,
+ "an automatic restore that threw dropped the arrival, so a queued checkpoint can "
+ + "replace the relay's only copy of it");
+ assertEquals(61L, offered.getSequence(), "a different state is on offer");
+ }
+
+ /**
+ * A checkpoint the storage refused keeps the parked state too.
+ *
+ * commit() used to be void, so a refused write ended there silently: the restore reported
+ * no failure, the slot was released, and a pending publish could erase the relay copy of a
+ * state with no durable copy anywhere and no acknowledgement.
+ */
+ @EdtTest
+ public void aRestoreWhoseCheckpointCannotBeStoredKeepsTheParkedState() {
+ Continuity.setAutoRestore(false);
+ Continuity.setStateProvider(new RecordingProvider());
+
+ AppState arrival = fromElsewhere("unstorable", 62L);
+ arrival.setRoutes(new ArrayList());
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(), "the arrival never parked");
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.STORAGE_KEY));
+ try {
+ Continuity.restore();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ AppState still = Continuity.getRestorableState();
+ assertNotNull(still,
+ "a restore whose checkpoint storage refused released the slot, so nothing "
+ + "durable holds this state and a queued publish can erase the relay's "
+ + "copy of it");
+ assertEquals(62L, still.getSequence(), "a different state is on offer");
+ }
+
+ /**
+ * The synced store reaches the platform without consulting the entitlement probe.
+ *
+ * The gate was on three layers -- the native store, the iOS bridge and the public facade --
+ * and removing it from the first two changed nothing, because the third still made every call
+ * unreachable. This drives the facade, which is the layer an application actually touches, and
+ * is the check that was missing when the first two "fixes" were called done.
+ */
+ @EdtTest
+ public void theSyncedStoreFacadeReachesTheBridgeWithoutTheProbe() {
+ // A bridge that reports the feature UNSUPPORTED while still holding values, which is
+ // exactly the iOS shape the fix is about: the entitlement probe has not succeeded, and
+ // the store underneath is a local one that works anyway.
+ Continuity.setBridge(new LocalContinuityBridge() {
+ @Override
+ public boolean isSyncedStoreSupported() {
+ return false;
+ }
+ });
+
+ assertFalse(SyncedStore.isSupported(),
+ "the fixture says the probe succeeded, so this proves nothing about the gate");
+ assertTrue(SyncedStore.put("draft", "half a sentence"),
+ "the facade refused a write on a bridge that would have taken it");
+ assertEquals("half a sentence", SyncedStore.get("draft", "nothing"),
+ "the facade refused to read a value the bridge is holding");
+
+ SyncedStore.remove("draft");
+ assertEquals("nothing", SyncedStore.get("draft", "nothing"),
+ "the facade did not reach the bridge to remove the key");
+ }
+
+ /**
+ * A failed relay read holds the publication until a read SUCCEEDS, not until the next
+ * checkpoint.
+ *
+ * Writing over the relay's single document is only safe because a poll established what
+ * was there; a timeout establishes nothing. The hold used to end the moment pollFinished()
+ * cleared {@code polling}, so the very next checkpoint published over a document this device
+ * had never managed to read.
+ */
+ @EdtTest
+ public void aFailedReadHoldsThePublicationUntilAReadSucceeds() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+
+ final java.util.concurrent.atomic.AtomicInteger fetches =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicBoolean readWorks =
+ new java.util.concurrent.atomic.AtomicBoolean(false);
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() throws java.io.IOException {
+ fetches.incrementAndGet();
+ if (!readWorks.get()) {
+ throw new java.io.IOException("no network");
+ }
+ return null;
+ }
+ });
+ pause(300L);
+ flushSerialCalls();
+ assertTrue(fetches.get() > 0, "the relay was never read, so no read has failed yet");
+ published.clear();
+
+ // The checkpoint that used to go out on the strength of a read that never happened.
+ provider.saved.put("after", "work done while offline");
+ Continuity.checkpoint();
+ pause(300L);
+ flushSerialCalls();
+ assertTrue(published.isEmpty(),
+ "a checkpoint was published over a relay document this device has never managed "
+ + "to read: published=" + published.size());
+
+ // And it is a hold, not a refusal: the work is still owed, and a read that succeeds
+ // releases it. Without that this test would pass on a relay that had simply stopped.
+ readWorks.set(true);
+ Continuity.checkpoint();
+ pause(500L);
+ flushSerialCalls();
+ assertFalse(published.isEmpty(),
+ "the state stayed owed for ever once a read had failed, so this device never "
+ + "publishes again for the life of the process");
+ }
+
+ /**
+ * Completing a newer state from an origin releases an older one parked from the same origin.
+ *
+ * A device can have two states in flight -- a continuation and a relay poll routinely carry
+ * different sequences -- so N can be parked while N+1 is admitted and restored. An identity
+ * comparison left N in the slot: it was still offered, restoring it would have walked the user
+ * and the stored checkpoint backwards, and the publication hold never lifted.
+ */
+ @EdtTest
+ public void completingANewerStateReleasesAnOlderOneFromTheSameOrigin() {
+ Continuity.setAutoRestore(false);
+ Continuity.setStateProvider(new RecordingProvider());
+
+ Continuity.deliver(fromElsewhere("the older screen", 70L));
+ flushSerialCalls();
+ AppState older = Continuity.getRestorableState();
+ assertNotNull(older, "nothing parked, so there is no predecessor to strand");
+ assertEquals(70L, older.getSequence(), "a different state parked");
+
+ // The same origin, further along. Acknowledging it is the origin saying where it is now.
+ Continuity.acknowledge(fromElsewhere("the newer screen", 71L));
+ flushSerialCalls();
+
+ AppState stranded = Continuity.getRestorableState();
+ assertTrue(stranded == null || stranded.getSequence() != 70L,
+ "the superseded state is still on offer, so restoring it walks the user backwards "
+ + "and the publication hold never lifts");
+ }
+
+ /**
+ * A synchronous acknowledgement survives a device id too long to keep a durable mark for.
+ *
+ * Two fixes meeting. The durable map is bounded by what one stored string can hold, so
+ * trimToWritable() can evict an entry the moment it goes in -- an id long enough to blow that
+ * budget on its own does exactly that. Asking only that map whether the arrival had been acted
+ * on then said no a microsecond after acknowledge() returned, and parked a finished state:
+ * still offered, with every relay checkpoint held behind it.
+ *
+ * Neither fix is wrong alone. The map answers what the next launch will know; this question
+ * is what this process has already done.
+ */
+ @EdtTest
+ public void aSynchronousAcknowledgementSurvivesAnIdTooLongToStore() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ Continuity.acknowledge(state);
+ return false;
+ }
+ });
+
+ // Exactly at the limit an AppState accepts for a device id, which is one byte per
+ // character here -- so the id is legal, and the MARK for it is not: the entry costs the
+ // id plus its separators and sequence, which is past what a single stored string holds,
+ // and the size budget evicts it the instant it goes in. A shorter id would fit and prove
+ // nothing; a longer one is refused by setDeviceId before this test begins, which is how
+ // the first version of it failed.
+ StringBuilder id = new StringBuilder("device-");
+ while (id.length() < 65535) {
+ id.append('d');
+ }
+ Map payload = new HashMap();
+ payload.put("note", "handled in the callback");
+ Continuity.deliver(new AppState()
+ .setPayload(payload)
+ .setDeviceId(id.toString())
+ .setSequence(81L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertNull(Continuity.getRestorableState(),
+ "an arrival the listener had acknowledged was parked because its durable mark did "
+ + "not fit, so it stays on offer and holds every checkpoint off the relay");
+ }
+
+ /**
+ * The simulated synced store reports a write that did not reach storage.
+ *
+ * It used to persist through {@code Preferences}, whose {@code set()} fills an in-memory
+ * table and whose {@code save()} discards the write's result -- and whose {@code get()} reads
+ * that table. The read-back therefore consulted the cache it had just written and agreed with
+ * itself, so put() reported success for a value that vanishes at the next launch. The
+ * simulator and the desktop app are what applications develop against, so this taught them
+ * something false about the device.
+ */
+ @EdtTest
+ public void theSimulatedStoreReportsAWriteThatDidNotReachStorage() {
+ // The control first: with storage working the same call must succeed, or an
+ // unconditional false would satisfy the assertion below and break the store.
+ assertTrue(SyncedStore.put("draft", "half a sentence"),
+ "the write failed with storage working, so the refusal below proves nothing");
+ assertEquals("half a sentence", SyncedStore.get("draft", "nothing"));
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingStorage());
+ try {
+ assertFalse(SyncedStore.put("draft", "a longer sentence"),
+ "a write storage refused was reported as success, so the value is gone at the "
+ + "next launch and the application was told it was saved");
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+ }
+
+ /**
+ * Restoring a foreign state withdraws the activity this device was advertising.
+ *
+ * The platform activity stays current until something replaces or withdraws it, and
+ * {@code applyingRestore} suppresses the checkpoint the rebuilt route stack would have
+ * triggered -- so the pre-restore screen went on being offered to every Apple device around
+ * until the user next navigated, and a third device could continue into a screen this one had
+ * already moved off.
+ */
+ @EdtTest
+ public void restoringAForeignStateWithdrawsTheStaleAdvertisement() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("screen", "the one this device was on");
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+
+ // What this device is advertising before anything arrives.
+ Continuity.checkpoint();
+ flushSerialCalls();
+ assertNotNull(bridge.getPublishedType(),
+ "nothing was advertised, so there is no stale activity for the restore to leave");
+
+ Continuity.deliver(fromElsewhere("what the other device was doing", 91L));
+ flushSerialCalls();
+ assertNotNull(provider.restored,
+ "the arrival was not applied, so this test is about nothing");
+
+ assertNull(bridge.getPublishedType(),
+ "the pre-restore activity is still advertised after restoring somebody else's "
+ + "state, so a third device continues into a screen this one has left");
+ }
+
+ /**
+ * A recovery read after a failed fetch never overlaps another read.
+ *
+ * The recovery branch was placed ABOVE the one-fetch-at-a-time guard, so a second
+ * checkpoint launched a second GET while the first was still in flight. Two overlapping reads
+ * can return different documents -- the relay holds one per user and the other device may
+ * replace it between them -- and nothing downstream re-orders the answers, so whichever
+ * finished first cleared {@code polling} and could release the publisher while the other was
+ * still outstanding.
+ */
+ @EdtTest
+ public void aRecoveryReadNeverOverlapsAnotherRead() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+
+ final java.util.concurrent.atomic.AtomicInteger live =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicInteger mostAtOnce =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicInteger fetches =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.CountDownLatch inRecovery =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch release =
+ new java.util.concurrent.CountDownLatch(1);
+
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() throws java.io.IOException {
+ int now = live.incrementAndGet();
+ synchronized (mostAtOnce) {
+ if (now > mostAtOnce.get()) {
+ mostAtOnce.set(now);
+ }
+ }
+ try {
+ if (fetches.incrementAndGet() == 1) {
+ // The first read fails, which is what arms the recovery path.
+ throw new java.io.IOException("no network");
+ }
+ // Every later read is held open, so a checkpoint arriving now would start a
+ // second one if anything still let it.
+ inRecovery.countDown();
+ release.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ return null;
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ return null;
+ } finally {
+ live.decrementAndGet();
+ }
+ }
+ });
+ pause(300L);
+ flushSerialCalls();
+ assertEquals(1, fetches.get(), "the first read did not happen, so nothing armed recovery");
+
+ // Starts the recovery read, which then blocks.
+ Continuity.checkpoint();
+ final boolean[] recovering = new boolean[1];
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ try {
+ recovering[0] = inRecovery.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ assertTrue(recovering[0], "no recovery read started, so there is nothing to overlap");
+
+ // The second checkpoint, while that read is still outstanding.
+ provider.saved.put("more", "work done meanwhile");
+ Continuity.checkpoint();
+ pause(300L);
+
+ release.countDown();
+ pause(400L);
+ flushSerialCalls();
+
+ assertEquals(1, mostAtOnce.get(),
+ "two relay reads were in flight at once, so whichever answered first could "
+ + "release a publish over a document the other had not seen");
+ }
+
+ /**
+ * A state with no origin is refused rather than admitted.
+ *
+ * Every mark is keyed by origin and sequence, so an empty origin is a single key shared by
+ * every producer that forgot to set one -- and noteActedOn() has to refuse such a state, which
+ * meant nothing was marked durably and the same state was restored again after every restart.
+ * Worse, a listener following the documented acknowledge() path left it parked for the life of
+ * the process, with relay publication held behind it.
+ */
+ @EdtTest
+ public void aStateWithNoOriginIsRefused() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ final int[] offered = new int[1];
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ offered[0]++;
+ return false;
+ }
+ });
+
+ Map payload = new HashMap();
+ payload.put("note", "from nowhere in particular");
+ // No setDeviceId at all, which is what a hand-built relay state or a document with no
+ // "device" member produces.
+ Continuity.deliver(new AppState()
+ .setPayload(payload)
+ .setSequence(5L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertEquals(0, offered[0],
+ "a state with no origin reached the application, and nothing can ever mark it "
+ + "handled");
+ assertNull(Continuity.getRestorableState(),
+ "an origin-less state was parked, so it is offered for ever and every relay "
+ + "checkpoint waits behind it");
+ }
+
+ /**
+ * A listener that logs out mid-callback stops the dispatch it is inside.
+ *
+ * Discovering that an arrival belongs to another account is exactly the decision this
+ * callback exists for, and calling clear() is the documented response. Dispatch carried on
+ * regardless: with automatic restore on it restored and PERSISTED the signed-out account's
+ * state, moments after logout had deleted it.
+ */
+ @EdtTest
+ public void aListenerThatLogsOutStopsTheDispatchItIsInside() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ // "This is not the account that is signed in" -- the documented reason to log out.
+ Continuity.clear();
+ return true;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("the previous account's work", 95L));
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "the signed-out account's state was restored after clear(), so its work is back "
+ + "on screen and back in storage moments after logout deleted it");
+ assertNull(Continuity.getRestorableState(),
+ "logout left something restorable behind");
+ }
+
+ /**
+ * And disable() inside the callback stops it too.
+ *
+ * disable() documents that arriving states are ignored from the moment it is called, which
+ * has to include the one being dispatched when it is called.
+ */
+ @EdtTest
+ public void aListenerThatDisablesStopsTheDispatchItIsInside() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ Continuity.disable();
+ return true;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("arrived as it was switched off", 96L));
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "a state was restored after disable(), which says arrivals are ignored from the "
+ + "moment it is called");
+ }
+
+ /**
+ * Logout removes the delivery marks even when the write that empties them is refused.
+ *
+ * rememberSeen() writes the emptied map, and a write storage refuses leaves the whole of
+ * the signed-out account's marks on disk for the next launch to reload. Which devices an
+ * account synced with, and how far, is that account's data as much as its routes are.
+ */
+ @EdtTest
+ public void logoutRemovesTheDeliveryMarksEvenWhenTheWriteIsRefused() {
+ Continuity.setStateProvider(new RecordingProvider());
+ // An acknowledged arrival, so there is a durable mark to lose.
+ Continuity.acknowledge(fromElsewhere("dealt with", 9L));
+ flushSerialCalls();
+ assertFalse(Continuity.readSeenForTest().isEmpty(),
+ "no mark was written, so logout has nothing to remove and this proves nothing");
+
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingOneStorage(original, Continuity.PREF_SEEN));
+ try {
+ Continuity.clear();
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ assertTrue(Continuity.readSeenForTest().isEmpty(),
+ "the signed-out account's delivery marks are still on disk, so the next launch "
+ + "reloads which devices it synced with and how far: "
+ + Continuity.readSeenForTest());
+ }
+
+ /**
+ * The relay's requests refuse redirects, because they carry a bearer token.
+ *
+ * A redirect is followed with the same headers, so a 307 hands the token and the state to
+ * whatever host the response names -- including an {@code http://} one, which silently undoes
+ * the HTTPS the constructor insists on. A 302 turns the POST into a GET and the 2xx that
+ * follows reports a write that never happened.
+ *
+ * Asked of the builder the relay actually produces, so it fails if the call is dropped
+ * from {@code auth()}, and paired with a control: a plain request still follows redirects,
+ * which is CodenameOne's default and not something this may change for everyone.
+ */
+ @EdtTest
+ public void theRelayRefusesRedirectsOnItsAuthenticatedRequests() throws Exception {
+ RestStateRelay relay = new RestStateRelay("https://example.invalid/continuity");
+ Continuity.setRelay(relay);
+
+ java.lang.reflect.Method auth = RestStateRelay.class.getDeclaredMethod(
+ "auth", RequestBuilder.class);
+ auth.setAccessible(true);
+ RequestBuilder built = (RequestBuilder) auth.invoke(
+ relay, Rest.post("https://example.invalid/continuity"));
+
+ assertEquals(Boolean.FALSE, redirectSetting(built),
+ "the relay's requests follow redirects, so a 307 forwards the bearer token and "
+ + "the state to whatever host the endpoint names");
+ // UNSPECIFIED, not "true". The setting is three-state on purpose: a request that never
+ // asked must leave ConnectionRequest's global default alone, in either direction, so
+ // asserting true here would have pinned the wrong contract -- an application that turned
+ // redirects off globally would still get them.
+ assertNull(redirectSetting(Rest.post("https://example.invalid/continuity")),
+ "an ordinary request now carries a redirect setting of its own, which overrides "
+ + "whatever the application chose globally");
+ }
+
+ /**
+ * An explicit followRedirects(true) reaches the request even when the global default is false.
+ *
+ * Asserted on the built {@code ConnectionRequest}, not on the builder's own field: the
+ * field is recorded either way, and the defect was in applying it. A first version of this
+ * check read the builder and passed against the broken code, which is the same wrong-layer
+ * mistake the probe exists to catch.
+ */
+ @EdtTest
+ public void anExplicitRedirectChoiceReachesTheRequest() throws Exception {
+ boolean previous = ConnectionRequest.isDefaultFollowRedirects();
+ ConnectionRequest.setDefaultFollowRedirects(false);
+ try {
+ assertTrue(builtFollowsRedirects(
+ Rest.post("https://example.invalid/x").followRedirects(true)),
+ "an explicit followRedirects(true) did not reach the request, so a per-request "
+ + "setting cannot override the global one");
+ assertFalse(builtFollowsRedirects(Rest.post("https://example.invalid/x")),
+ "a request that never asked stopped inheriting the global default");
+ } finally {
+ ConnectionRequest.setDefaultFollowRedirects(previous);
+ }
+ }
+
+ /// What the builder actually hands to the network layer.
+ private static boolean builtFollowsRedirects(RequestBuilder b) throws Exception {
+ java.lang.reflect.Method m =
+ RequestBuilder.class.getDeclaredMethod("createRequest", boolean.class);
+ m.setAccessible(true);
+ return ((ConnectionRequest) m.invoke(b, Boolean.FALSE)).isFollowRedirects();
+ }
+
+ /// The builder's redirect setting: TRUE, FALSE, or null for "the caller did not say".
+ private static Boolean redirectSetting(RequestBuilder b) throws Exception {
+ java.lang.reflect.Field f = RequestBuilder.class.getDeclaredField("followRedirects");
+ f.setAccessible(true);
+ return (Boolean) f.get(b);
+ }
+
+ /**
+ * A restore cancels the checkpoint a navigation had already scheduled.
+ *
+ * routeStackChanged() sets the pending flag and queues a flush, and that flush asks only
+ * whether a checkpoint is pending. So it ran after the restore, captured the state that had
+ * just ARRIVED under this device's identity, and published the echo the restore path exists
+ * to suppress -- which the origin then accepts and restores on its next poll.
+ */
+ @EdtTest
+ public void aRestoreCancelsTheCheckpointANavigationHadScheduled() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("screen", "before the arrival");
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ });
+ pause(200L);
+ flushSerialCalls();
+ published.clear();
+
+ // The ORDER is the whole test, and getting it wrong is why the first version passed
+ // against the unfixed code. deliver() queues admission, admission queues the dispatch a
+ // turn later, and the navigation has to land BETWEEN them -- so its flush is queued
+ // behind the dispatch and runs after the restore has committed. Calling
+ // routeStackChanged() directly put the flush FIRST, where checkpoint() cleared the
+ // pending flag itself and the fix could not be observed at all.
+ Continuity.deliver(fromElsewhere("what the other device was doing", 97L));
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ Continuity.routeStackChanged();
+ assertTrue(Continuity.isCheckpointPending(),
+ "the navigation scheduled nothing, so there is no capture to cancel");
+ }
+ });
+ flushSerialCalls();
+ assertNotNull(provider.restored, "the arrival was not applied, so this test is vacuous");
+
+ // Asserted on what reached the RELAY, not on the pending flag. checkpoint() clears that
+ // flag itself, so it reads false whether the capture was cancelled or performed -- a
+ // second version of this test asserted on it and passed against the unfixed code.
+ pause(300L);
+ flushSerialCalls();
+ assertTrue(published.isEmpty(),
+ "the checkpoint scheduled before the restore still ran, so the arrival went back "
+ + "out under this device's id -- the echo the origin then accepts and "
+ + "restores on its next poll: published=" + published.size());
+ }
+
+ /**
+ * Keys that Storage would fold together stay distinct in the simulated store.
+ *
+ * Storage normalizes {@code /}, {@code %}, {@code ?}, {@code *}, {@code :} and {@code =}
+ * to {@code _} in a file name, so "a/b" and "a_b" addressed the same value: both writes
+ * reported success, the index listed both keys, and either read returned whichever was
+ * written last while removing one deleted the other. That arrived with the move off
+ * Preferences, which has no such rule -- a defect introduced while fixing a different one.
+ */
+ @EdtTest
+ public void keysThatStorageWouldFoldTogetherStayDistinct() {
+ assertTrue(SyncedStore.put("a/b", "slash"), "the first key was refused");
+ assertTrue(SyncedStore.put("a_b", "underscore"), "the second key was refused");
+
+ assertEquals("slash", SyncedStore.get("a/b", "missing"),
+ "\"a/b\" reads back the value written under \"a_b\", so the two share a "
+ + "storage name");
+ assertEquals("underscore", SyncedStore.get("a_b", "missing"),
+ "\"a_b\" lost its own value");
+
+ // And removing one must not take the other with it.
+ SyncedStore.remove("a/b");
+ assertEquals("missing", SyncedStore.get("a/b", "missing"), "the removal did not happen");
+ assertEquals("underscore", SyncedStore.get("a_b", "missing"),
+ "removing \"a/b\" deleted the value stored under \"a_b\"");
+ }
+
+ /**
+ * Forgetting the back history is a change worth checkpointing.
+ *
+ * clearStack() stayed silent so that logout could call it without checkpointing the
+ * emptied stack back over the storage it was deleting -- which made it silent for every other
+ * caller too. An application that forgot its history and did not then navigate left the
+ * previous routes in the stored checkpoint, so a process death restored exactly what it had
+ * just cleared.
+ */
+ @EdtTest
+ public void forgettingTheBackHistoryIsCheckpointed() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.checkpoint();
+ assertFalse(Continuity.isCheckpointPending(), "the fixture starts with nothing owed");
+
+ Navigation.clearStack();
+
+ assertTrue(Continuity.isCheckpointPending(),
+ "clearing the back history scheduled no checkpoint, so the previous routes stay "
+ + "in storage and a process death restores what was just cleared");
+ }
+
+ /**
+ * A continuation arriving while continuity is off is DECLINED, so the port can hold it.
+ *
+ * SyncedStore.addChangeListener() installs the same callback without enabling continuity --
+ * a key/value store is not consent to restore a route stack -- and on a cold launch that
+ * happens before the application's init() calls enable(). The iOS port holds a declined
+ * activity and offers it again when the next callback is installed; claiming it instead threw
+ * it away, because admit() drops an arrival while the framework is disabled. An application
+ * that registered a store listener first lost its Handoff for good.
+ */
+ @EdtTest
+ public void aContinuationArrivingBeforeEnableIsDeclinedRatherThanSwallowed() {
+ // NOTHING SAID YET, which is the window the port retains for, and which the per-test
+ // reset() already gives. This used to call disable() to force the state -- harmless when
+ // disable() was a no-op before the first enable(), and wrong once it began recording the
+ // application's choice: an explicit "off" is a different answer from silence, and the
+ // sibling test below is about that one.
+ ContinuityCallback callback = Continuity.callbackForTest();
+
+ Map info = StateCodec.toMap(fromElsewhere("from the other device", 88L));
+ boolean claimed = callback.continuationReceived(Continuity.getActivityType(), info);
+
+ assertFalse(claimed,
+ "the callback claimed a continuation while continuity was disabled, so the port "
+ + "discards it and the enable() moments later has nothing to deliver");
+
+ // And once enabled it IS claimed, or the decline above would just be a feature that never
+ // works.
+ Continuity.enable();
+ assertTrue(callback.continuationReceived(Continuity.getActivityType(), info),
+ "an enabled framework refused its own activity type");
+ }
+
+ /**
+ * Two keys differing only in case are two keys in the simulated store.
+ *
+ * The default filesystems on macOS and Windows are case-insensitive, so "Theme" and
+ * "theme" resolved to one file: the second put() overwrote the first while the index listed
+ * both, both reads answered with one value, and removing either removed both. The store being
+ * simulated is case-sensitive, and a simulation that merges two keys is worse than no
+ * simulation -- it looks like it works.
+ */
+ @EdtTest
+ public void twoKeysDifferingOnlyInCaseAreTwoKeys() {
+ // Through a storage that FOLDS CASE, which is what the default macOS and Windows
+ // filesystems do and what this test is about. The unit-test storage is case-sensitive, so
+ // a first version of this test passed against the unfixed code: it asserted nothing.
+ Storage real = Storage.getInstance();
+ Storage.setStorageInstance(new CaseFoldingStorage(real));
+ LocalContinuityBridge b = new LocalContinuityBridge();
+ try {
+ assertTrue(b.syncedStorePut("Theme", "upper"), "the fixture could not write a value");
+ assertTrue(b.syncedStorePut("theme", "lower"), "the fixture could not write a value");
+
+ assertEquals("upper", b.syncedStoreGet("Theme"),
+ "the lowercase write overwrote the uppercase key's value, so two distinct "
+ + "keys share one file");
+ assertEquals("lower", b.syncedStoreGet("theme"), "the lowercase value did not survive");
+
+ b.syncedStoreRemove("theme");
+ assertEquals("upper", b.syncedStoreGet("Theme"),
+ "removing one key removed the other as well");
+ assertNull(b.syncedStoreGet("theme"), "the removed key still reads back");
+ } finally {
+ b.syncedStoreRemove("Theme");
+ b.syncedStoreRemove("theme");
+ Storage.setStorageInstance(real);
+ }
+ }
+
+ /**
+ * Minting the device id does not go anywhere near a web view.
+ *
+ * enable() mints an origin id on first use, and it used to do that through
+ * Util.getUUID(). Instantiating Util.UUID runs a static initialiser that seeds itself from
+ * getUniqueDeviceID(), which reaches CN.getProperty("User-Agent") -- and the Apple ports
+ * answer that by standing up a web view. tvOS has no WebKit at all, so the call never
+ * returned: enable() hung on the event thread and the device suite stopped dead on the first
+ * test that installs a state provider, taking every test after it with it. The iOS console
+ * shows a thousand WebKit lines starting at that instant; the tvOS one has none.
+ *
+ */
+ @EdtTest
+ public void mintingTheDeviceIdAsksForNoDeviceProperties() {
+ Continuity.reset();
+ Storage.getInstance().clearStorage();
+ Continuity.setBridge(new LocalContinuityBridge());
+ Continuity.enable();
+
+ String id = Continuity.getDeviceId();
+ assertNotNull(id, "enabling minted no device id");
+ // The SHAPE, which is what a test can actually check here: the JavaSE port answers
+ // getProperty("User-Agent") out of a table and never builds a browser, so the hang
+ // itself cannot be reproduced in this harness. What can be pinned is that the id is
+ // this framework's own and not a Util.UUID -- reverting to Util.getUUID() produces a
+ // dashed UUID and fails this line, which is the whole point of asserting it.
+ assertTrue(id.startsWith("cn1-"),
+ "the origin id was not minted here but handed over by Util.getUUID(), whose "
+ + "static initialiser reads getProperty(\"User-Agent\") -- a call the "
+ + "Apple ports answer with a web view, and one tvOS never returns from");
+
+ // Minted ONCE and then persisted: a second enable in the same install keeps it, which is
+ // what makes this device recognise its own echo from the relay.
+ String again = Continuity.getDeviceId();
+ assertEquals(id, again, "the device id changed within one install");
+
+ // And two fresh installs do not collide, which is what the discarded UUID was for.
+ Continuity.reset();
+ Storage.getInstance().clearStorage();
+ Continuity.setBridge(new LocalContinuityBridge());
+ Continuity.enable();
+ assertFalse(id.equals(Continuity.getDeviceId()),
+ "two installs minted the same origin id, so each would drop the other's states "
+ + "as its own echo");
+ }
+
+ /**
+ * A callback delivered from a foreign thread is still claimed and still delivered.
+ *
+ * A REGRESSION GUARD, not a probe of the change it came with. The decision moved onto the
+ * event thread because it reads `enabled` and the application's choice, which that thread
+ * owns -- reading them from elsewhere was argued safe in one direction, since a decline is
+ * recoverable when the port retains and re-offers, and that argument died the moment the
+ * "off" answer became a CLAIM: a claim drops the activity, so a stale read there loses an
+ * arrival outright rather than delaying it.
+ *
+ * That is a memory-visibility fix and nothing in a test can demonstrate it: the harness
+ * has no failing publication to expose, and this test passes against the code without it.
+ * What it does pin is the behaviour the marshalling must not break -- a port calling from its
+ * own thread is told the activity was taken, and the arrival actually arrives.
+ */
+ @EdtTest
+ public void aCallbackFromAForeignThreadIsStillClaimedAndDelivered() {
+ Continuity.enable();
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ final ContinuityCallback callback = Continuity.callbackForTest();
+ final Map info =
+ StateCodec.toMap(fromElsewhere("from a background thread", 270L));
+ final java.util.concurrent.atomic.AtomicBoolean claimed =
+ new java.util.concurrent.atomic.AtomicBoolean();
+ final java.util.concurrent.atomic.AtomicBoolean onEdt =
+ new java.util.concurrent.atomic.AtomicBoolean(true);
+ final java.util.concurrent.CountDownLatch done =
+ new java.util.concurrent.CountDownLatch(1);
+
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ onEdt.set(Display.getInstance().isEdt());
+ claimed.set(callback.continuationReceived(Continuity.getActivityType(), info));
+ done.countDown();
+ }
+ }, "continuity foreign caller").start();
+
+ for (int i = 0; i < 40 && done.getCount() > 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(0L, done.getCount(), "the foreign caller never returned");
+ assertFalse(onEdt.get(), "the fixture ran on the event thread, so it tests nothing");
+ assertTrue(claimed.get(),
+ "the framework declined an arrival it had taken responsibility for, so the port "
+ + "is entitled to hand it to something else");
+
+ for (int i = 0; i < 20 && provider.restored == null; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertNotNull(provider.restored,
+ "the arrival was claimed and then never delivered, which is the one outcome a "
+ + "claim must not produce");
+ }
+
+ /**
+ * An inbound device id too long to store is a failed read, not a parked arrival.
+ *
+ * Refused where an oversized title is dropped, and the two are not alike: a title is a
+ * label a receiving device may show, while the origin id is the key every mark and every dedup
+ * decision is made against -- admit() refuses a state without one anyway. Carrying it is what
+ * does damage: commit() writes it through Util.writeUTF, which throws every time, so the
+ * arrival is parked, re-applied on every retry and holds every relay publication behind it.
+ */
+ @EdtTest
+ public void anInboundDeviceIdTooLongToStoreIsAFailedRead() throws Exception {
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('d');
+ }
+ Map wire = new HashMap();
+ wire.put("device", huge.toString());
+ wire.put("seq", "280");
+ wire.put("routes", java.util.Arrays.asList("/orders"));
+
+ assertNull(StateCodec.fromMap(wire),
+ "an origin id no checkpoint can hold was carried into the state, so commit() "
+ + "throws on it every time and the arrival is never let go of");
+ }
+
+ /**
+ * A continuation declined before enable() is delivered by the enable().
+ *
+ * The interaction between two earlier fixes. A sync-only application installs the seam
+ * through SyncedStore.addChangeListener and leaves continuity off, so an arrival is declined
+ * and the port holds it -- that is the retention contract working. Making installation
+ * strictly once then removed the only event that asks the port to offer it again, so the
+ * enable() that came with the login never saw it and the cold-launch Handoff was lost.
+ */
+ @EdtTest
+ public void aContinuationDeclinedBeforeEnableIsDeliveredByTheEnable() {
+ HoldingBridge holding = new HoldingBridge();
+ Continuity.setBridge(holding);
+ SyncedStoreListener listener = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ try {
+ // The sync-only path: a seam exists, continuity does not.
+ SyncedStore.addChangeListener(listener);
+ holding.pending = StateCodec.toMap(fromElsewhere("cold-launch handoff", 330L));
+ ContinuityCallback c = Continuity.callbackForTest();
+ assertFalse(c.continuationReceived(Continuity.getActivityType(), holding.pending),
+ "the arrival was claimed before the application had chosen, so the port let "
+ + "go of it and there is nothing left to deliver");
+
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ flushSerialCalls();
+ flushSerialCalls();
+
+ assertNull(holding.pending,
+ "the port is still holding the arrival, so enabling never asked for it");
+ assertNotNull(provider.restored,
+ "the continuation declined before enable() was never delivered by it, so a "
+ + "Handoff that cold-launched the app is lost for good");
+ } finally {
+ SyncedStore.removeChangeListener(listener);
+ }
+ }
+
+ /**
+ * Installing the seam does not grow with the number of listeners.
+ *
+ * This asserted "exactly one" and that was the wrong invariant, which only showed once the
+ * retention path needed a re-offer. Installing a callback is ALSO how a port is asked to hand
+ * over a continuation it declined earlier and is holding, so a framework that installed
+ * strictly once stranded that arrival -- see the sibling test below.
+ *
+ * What must not happen is the reported harm: every SyncedStore.addChangeListener()
+ * reaching the install path, so a port that registers a native observer there keeps one per
+ * listener and delivers every store change that many times. That is unbounded in application
+ * code. The re-offers are bounded and deliberate -- enable(), disable(), clear(), and a
+ * swapped bridge -- and a port is told so.
+ */
+ @EdtTest
+ public void installingTheSeamDoesNotGrowWithTheNumberOfListeners() {
+ CountingBridge counting = new CountingBridge();
+ Continuity.setBridge(counting);
+ SyncedStoreListener first = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ SyncedStoreListener second = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ try {
+ SyncedStore.addChangeListener(first);
+ assertEquals(1, counting.callbacks, "the first listener installed no seam");
+ SyncedStore.addChangeListener(second);
+ SyncedStore.addChangeListener(new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ });
+ assertEquals(1, counting.callbacks,
+ "the bridge was given " + counting.callbacks + " callbacks for 3 listeners, "
+ + "so a port that registers a native observer in setCallback keeps "
+ + "one per listener and delivers each store change that many times");
+
+ // enable() DOES re-install, on purpose: that is how the port is asked for a
+ // continuation it declined while continuity was off.
+ Continuity.enable();
+ assertEquals(2, counting.callbacks,
+ "enabling did not ask the port for anything it had held, so a Handoff that "
+ + "cold-launched the app before anything was listening is stranded");
+
+ // And a bridge the port SWAPS in still gets one, or refreshBridge() would be inert.
+ CountingBridge replacement = new CountingBridge();
+ Continuity.setBridge(replacement);
+ Continuity.refreshBridge();
+ assertEquals(1, replacement.callbacks,
+ "a swapped-in bridge was left with no callback, so every inbound "
+ + "continuation and store notification goes nowhere");
+ } finally {
+ SyncedStore.removeChangeListener(first);
+ SyncedStore.removeChangeListener(second);
+ }
+ }
+
+ /**
+ * A raw JSON boolean in an untagged document stays a boolean.
+ *
+ * The parser defaults to answering true and false with the strings "true" and "false".
+ * That is harmless for the tagged form this codec writes -- "b:true" is a string either way --
+ * and wrong for an untagged compatibility document from a hand-written endpoint: the payload
+ * reached the listeners and the provider with Strings where the sender wrote booleans, passed
+ * validation because a String is a representable type, and was acknowledged.
+ */
+ @EdtTest
+ public void anUntaggedJsonBooleanStaysABoolean() throws Exception {
+ AppState back = StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"10\",\"payload\":{\"on\":true,\"off\":false}}");
+ assertNotNull(back, "the document was refused");
+ assertEquals(Boolean.TRUE, back.getPayload().get("on"),
+ "a raw JSON true reached the application as " + back.getPayload().get("on"));
+ assertEquals(Boolean.FALSE, back.getPayload().get("off"),
+ "a raw JSON false reached the application as " + back.getPayload().get("off"));
+
+ // The tagged form this codec writes is unaffected.
+ AppState tagged = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\","
+ + "\"enc\":\"1\",\"payload\":{\"on\":\"b:true\"}}");
+ assertEquals(Boolean.TRUE, tagged.getPayload().get("on"),
+ "the tagged form stopped decoding");
+ }
+
+ /**
+ * Keys the host filesystem would merge stay distinct, whatever rule it merges them by.
+ *
+ * The character list this replaces grew by one entry per review round and was wrong every
+ * time in a new way: case folding, a trimmed trailing dot, characters Windows refuses, and
+ * canonically equivalent Unicode. Each merged two distinct keys into one value file while the
+ * index listed both. The whitelist makes every name pure ASCII out of characters no
+ * filesystem rewrites, so the question is closed rather than answered once more.
+ */
+ @EdtTest
+ public void keysTheFilesystemWouldMergeStayDistinct() {
+ // A storage that folds case, trims trailing dots and spaces, AND normalises accents --
+ // every rule a host has been observed to apply, at once. Nothing here should reach it in
+ // a form it can change.
+ Storage real = Storage.getInstance();
+ Storage.setStorageInstance(new HostileNamingStorage(real));
+ LocalContinuityBridge b = new LocalContinuityBridge();
+ String[] keys = {"theme", "Theme", "theme.", "theme ", "caf\u00e9", "cafe\u0301"};
+ try {
+ for (int i = 0; i < keys.length; i++) {
+ assertTrue(b.syncedStorePut(keys[i], "value" + i),
+ "the fixture could not write " + keys[i]);
+ }
+ for (int i = 0; i < keys.length; i++) {
+ assertEquals("value" + i, b.syncedStoreGet(keys[i]),
+ "key " + i + " reads back another key's value, so two distinct keys share "
+ + "one file while the index lists both");
+ }
+ // And removing one leaves the rest alone.
+ b.syncedStoreRemove(keys[0]);
+ assertNull(b.syncedStoreGet(keys[0]), "the removed key still reads back");
+ for (int i = 1; i < keys.length; i++) {
+ assertEquals("value" + i, b.syncedStoreGet(keys[i]),
+ "removing one key removed key " + i + " as well");
+ }
+ } finally {
+ for (int i = 0; i < keys.length; i++) {
+ b.syncedStoreRemove(keys[i]);
+ }
+ Storage.setStorageInstance(real);
+ }
+ }
+
+ /**
+ * An inbound title too long to store is dropped, and the state it came with is kept.
+ *
+ * Carrying it was the worst of the three answers: commit() persists it through
+ * externalize(), which throws on the oversized string every time, so the arrival is parked,
+ * re-applied on every retry and holds every relay publication behind it -- after the provider
+ * and the route rebuild have already run. Refusing the whole document would cost the user
+ * their work over a label a receiving device may show.
+ */
+ @EdtTest
+ public void anInboundTitleTooLongToStoreIsDroppedAndTheStateKept() throws Exception {
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ Map wire = new HashMap();
+ wire.put("device", "some-other-device");
+ wire.put("seq", "260");
+ wire.put("routes", java.util.Arrays.asList("/orders"));
+ wire.put("title", huge.toString());
+
+ AppState back = StateCodec.fromMap(wire);
+ assertNotNull(back, "the whole document was refused over a label");
+ assertNull(back.getTitle(), "the oversized title was carried, so the checkpoint that "
+ + "tries to store it throws on every retry for ever");
+ assertEquals(java.util.Arrays.asList("/orders"), back.getRoutes(),
+ "the user's actual work did not survive");
+ }
+
+ /**
+ * A route factory that throws fails the whole restore rather than skipping one screen.
+ *
+ * Skipping it made a throwing factory the same as a route this build no longer registers:
+ * the failed screen was passed over, an EARLIER one was shown, and restoreStack() reported
+ * success -- so the state was persisted and acknowledged, the relay stopped offering it, and
+ * the user was left on the wrong screen with no copy left to retry from.
+ *
+ * An unregistered route answers null and is still skipped, which is the tolerance that was
+ * wanted: it will not start working on the next launch either.
+ */
+ @EdtTest
+ public void aRouteFactoryThatThrowsFailsTheRestore() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ if ("/orders/17".equals(url)) {
+ // The screen the user was actually on, and its dependency is not up yet.
+ throw new IllegalStateException("the order screen could not be built");
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Map payload = new HashMap();
+ payload.put("draft", "worth keeping");
+ AppState arriving = new AppState()
+ .setPayload(payload)
+ .setRoutes(java.util.Arrays.asList("/orders", "/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(250L)
+ .setTimestamp(System.currentTimeMillis());
+
+ assertFalse(Continuity.restore(arriving),
+ "the restore reported a shown form although the screen the user was on could "
+ + "not be built");
+ flushSerialCalls();
+
+ assertNull(Continuity.readSeenForTest().get("some-other-device"),
+ "the partial restore was acknowledged durably, so the relay stops offering "
+ + "the state and there is nothing left to retry from -- while the "
+ + "user sits on a screen they did not ask for");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A key ending in a dot is not the same key as one without it.
+ *
+ * Windows normalises a trailing dot or space away, so "theme" and "theme." resolved to one
+ * value file while the index listed both: both reads answered with the last write, and
+ * removing either removed the other's value. The store being simulated is doing none of that.
+ * The sibling test covers case folding; this is the same class of collision through a
+ * different rule.
+ */
+ @EdtTest
+ public void aKeyEndingInADotIsNotTheSameKey() {
+ Storage real = Storage.getInstance();
+ Storage.setStorageInstance(new SuffixTrimmingStorage(real));
+ LocalContinuityBridge b = new LocalContinuityBridge();
+ try {
+ assertTrue(b.syncedStorePut("theme", "plain"), "the fixture could not write a value");
+ assertTrue(b.syncedStorePut("theme.", "dotted"), "the fixture could not write a value");
+
+ assertEquals("plain", b.syncedStoreGet("theme"),
+ "the dotted key overwrote the plain one, so two distinct keys share one file");
+ assertEquals("dotted", b.syncedStoreGet("theme."), "the dotted value did not survive");
+
+ b.syncedStoreRemove("theme.");
+ assertEquals("plain", b.syncedStoreGet("theme"),
+ "removing one key removed the other as well");
+ } finally {
+ b.syncedStoreRemove("theme");
+ b.syncedStoreRemove("theme.");
+ Storage.setStorageInstance(real);
+ }
+ }
+
+ /**
+ * An arrival queued behind a logout is not admitted into the session that follows it.
+ *
+ * deliver() marshals from the platform's thread, so an arrival that a port handed over
+ * before the logout can find clear() already queued ahead of it. admit() then ran after
+ * clear(), read the NEW generation, and every later check passed: the previous account's
+ * state was restored and persisted after the logout that promises nothing from before it
+ * survives. The second-turn dispatch carried a generation and this first hop carried none.
+ */
+ @EdtTest
+ public void anArrivalQueuedBehindALogoutIsNotAdmitted() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+
+ // The order the bug needs: the arrival is handed over FIRST -- deliver() queues admit()
+ // -- and the logout is queued behind it... no: the logout must run BEFORE admit() does,
+ // which is what happens when the port delivers while a logout is already on the queue.
+ // Queued here in that order, then drained together.
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ Continuity.clear();
+ }
+ });
+ Continuity.deliver(fromElsewhere("the previous account's work", 230L));
+ flushSerialCalls();
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "a state that arrived before the logout was admitted into the session after it, "
+ + "and restored the previous account's work");
+ assertNull(Continuity.readSeenForTest().get("some-other-device"),
+ "it was marked durably too, so the origin's real states are refused as already "
+ + "seen after a restart");
+ }
+
+ /**
+ * An acknowledgement from a session that has ended is refused, like a restore from one.
+ *
+ *
The same hold-it-and-come-back-later pattern arriving through the other door. Marking it
+ * recreates a durable high-water mark for the signed-out account, so a state the NEXT account
+ * sends from that same device with a lower sequence is discarded as already handled.
+ */
+ @EdtTest
+ public void anAcknowledgementFromAnEndedSessionIsRefused() {
+ final AppState[] held = new AppState[1];
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ held[0] = state;
+ return false;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("the previous account's work", 240L));
+ flushSerialCalls();
+ assertNotNull(held[0], "the listener never got the arrival, so this test is about nothing");
+
+ Continuity.clear();
+ Continuity.enable();
+
+ // The prompt finishes late, after the logout.
+ Continuity.acknowledge(held[0]);
+ flushSerialCalls();
+
+ assertNull(Continuity.readSeenForTest().get("some-other-device"),
+ "the signed-out account's sequence became this session's durable high-water mark, "
+ + "so a lower-numbered state the NEXT account sends from that same device "
+ + "is discarded as already handled");
+ }
+
+ /**
+ * A state a listener was holding is refused once the session it arrived in has ended.
+ *
+ * Returning false to keep an arrival while prompting the user is documented behaviour, and
+ * the object handed back to restore() later carried nothing about the session it came from.
+ * A clear() while the prompt was up -- and a login for another account after it -- still
+ * restored the previous account's payload, routes and checkpoint. clear() cannot reach into
+ * the application to take the object away, so it is refused on the way back in.
+ */
+ @EdtTest
+ public void aStateHeldAcrossALogoutIsRefusedWhenItComesBack() {
+ final AppState[] held = new AppState[1];
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ // "Keep it, I will prompt and call restore() when the user accepts."
+ held[0] = state;
+ return false;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("the previous account's work", 220L));
+ flushSerialCalls();
+ assertNotNull(held[0], "the listener never got the arrival, so this test is about nothing");
+
+ // The user signs out while the prompt is up, and signs in as somebody else.
+ Continuity.clear();
+ Continuity.enable();
+ Continuity.setStateProvider(provider);
+ provider.restored = null;
+
+ // ... and only then taps "continue".
+ assertFalse(Continuity.restore(held[0]),
+ "a state from the previous session reported a restored screen");
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "the previous account's payload was restored into the next account's session, "
+ + "after a clear() that promises nothing from before it survives");
+ }
+
+ /**
+ * A RestStateRelay the application drives itself is allowed to send.
+ *
+ * The guard is about an object kept across a setRelay() and used afterwards, which would
+ * send one account's state under another account's credentials. A relay that was never
+ * installed has no session to confuse -- and asking only "is this the installed relay" made
+ * every publish() and fetch() on a standalone instance throw before issuing a request, for a
+ * public class with a public constructor.
+ */
+ @EdtTest
+ public void aRelayTheApplicationDrivesItselfMaySend() {
+ // Nothing installed at all, which is how a standalone relay is used.
+ StateRelay standalone = new StateRelay() {
+ public void publish(AppState state) {
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ };
+ assertTrue(Continuity.mayRelaySend(standalone),
+ "a relay the framework was never given was refused, so an application using "
+ + "RestStateRelay on its own cannot send anything");
+
+ // And the case the guard is actually for still refuses.
+ StateRelay installed = new StateRelay() {
+ public void publish(AppState state) {
+ }
+
+ public AppState fetch() {
+ return null;
+ }
+ };
+ Continuity.setRelay(installed);
+ assertFalse(Continuity.mayRelaySend(standalone),
+ "a relay that is not the installed one was allowed to send, so an object kept "
+ + "across a setRelay() can still send the previous account's state");
+ assertTrue(Continuity.mayRelaySend(installed), "the installed relay was refused");
+ }
+
+ /**
+ * An empty route string is a failed read, not an arrival that can never be applied.
+ *
+ * It is a string, so it passes every type check, and the state is therefore not empty and
+ * not read as a tombstone. Then restoreStack() skips the path, rebuilds nothing, and the
+ * arrival is classified as an attempt that failed: parked for ever, re-offered on every
+ * launch, with every relay publication held behind it.
+ *
+ * Refused rather than filtered, because dropping the only route turns the document into an
+ * empty state -- which means the sending device cleared its work, something else entirely.
+ * Nothing this framework writes produces one: setRoutes() skips empty paths.
+ */
+ @EdtTest
+ public void anEmptyRouteStringIsAFailedRead() throws Exception {
+ try {
+ AppState s = StateCodec.fromJson("{\"device\":\"d\",\"seq\":\"1\",\"routes\":[\"\"]}");
+ fail("an empty route string was accepted"
+ + (s != null && !s.isEmpty()
+ ? " -- and the state is NOT empty, so it is not a tombstone either: "
+ + "it can never be applied and is never let go of"
+ : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ // A real route alongside one is refused too: the sender meant two screens, and quietly
+ // rebuilding one of them is not the same state.
+ try {
+ StateCodec.fromJson("{\"device\":\"d\",\"seq\":\"1\",\"routes\":[\"/a\",\"\"]}");
+ fail("an empty route beside a real one was accepted");
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+ }
+
+ /**
+ * A nested collection in a delivered payload cannot be mutated by the application.
+ *
+ * The outer map was unmodifiable and everything inside it was not. That matters most for
+ * an arrival: the same AppState handed to a listener or a provider is afterwards parked,
+ * persisted, acknowledged and published, so a provider that consumed a nested list -- removing
+ * items as it applied them, which is an ordinary way to write that loop -- changed the
+ * framework's own snapshot of what arrived. setPayload() deep-copies on the way in for
+ * exactly this reason; the way out had to match.
+ */
+ @EdtTest
+ public void aNestedPayloadCollectionCannotBeMutatedByTheApplication() {
+ List items = new ArrayList();
+ items.add("first");
+ items.add("second");
+ Map nested = new HashMap();
+ nested.put("deeper", "value");
+ Map payload = new HashMap();
+ payload.put("items", items);
+ payload.put("nested", nested);
+
+ AppState state = new AppState().setPayload(payload)
+ .setDeviceId("some-other-device").setSequence(210L)
+ .setTimestamp(System.currentTimeMillis());
+
+ Object handed = state.getPayload().get("items");
+ assertTrue(handed instanceof List, "the payload did not survive as a list");
+ try {
+ ((List) handed).remove(0);
+ fail("a nested list in the delivered payload was mutable, so an application that "
+ + "consumes its items as it applies them rewrites the framework's snapshot -- "
+ + "which is then parked, persisted, acknowledged and published");
+ } catch (UnsupportedOperationException expected) {
+ // What an unmodifiable view is for.
+ }
+
+ Object deep = state.getPayload().get("nested");
+ assertTrue(deep instanceof Map, "the payload did not survive as a map");
+ try {
+ ((Map) deep).put("deeper", "changed");
+ fail("a nested map in the delivered payload was mutable");
+ } catch (UnsupportedOperationException expected) {
+ // As above.
+ }
+
+ // And the state itself is untouched, which is the point of all of it.
+ assertEquals(2, ((List>) state.getPayload().get("items")).size(),
+ "the framework's snapshot changed");
+ }
+
+ /**
+ * A screen the restored form's callback navigated to keeps its stack entry when a later
+ * listener throws.
+ *
+ * The display half of this rollback was gated first and the stack half left
+ * unconditional, which made the two disagree: a show callback that navigates somewhere of
+ * its own and then throws has already changed both, so erasing the stack while leaving its
+ * screen up describes a place the user is not -- back() then works on a history that does
+ * not include what is in front of them.
+ */
+ @EdtTest
+ public void aScreenTheCallbackNavigatedToKeepsItsStackEntry() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ if ("/orders/17".equals(url)) {
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ if (Navigation.getCurrent() == null
+ || !"/replacement".equals(Navigation.getCurrent().getPath())) {
+ Navigation.navigate("/replacement");
+ throw new IllegalStateException("and then this failed");
+ }
+ }
+ });
+ }
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(400L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertNotNull(Navigation.getCurrent(), "the stack was emptied altogether");
+ assertEquals("/replacement", Navigation.getCurrent().getPath(),
+ "the rollback erased the screen the callback navigated to, so back() works "
+ + "on a history that does not include what the user is looking at; "
+ + "top is " + Navigation.getCurrent().getPath());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A login form opened by the restored form's own show callback survives.
+ *
+ * restoreStack() rolls its screen back when show() throws, and it used to do that whenever
+ * the display had changed -- which is true of two different things. show() installs the form
+ * and only THEN runs its listeners, so a listener that finds the session expired, calls
+ * clear(), opens a login form and then throws has already replaced the screen with its own
+ * choice. Re-showing the pre-restore form put the signed-out account's screen back in front
+ * of the user, which is the one thing that callback ran to prevent -- and Continuity.restore()
+ * cannot correct it, because by the time it runs the rollback has happened.
+ */
+ @EdtTest
+ public void aLoginFormOpenedByAFailingShowCallbackSurvivesTheRollback() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+
+ Form dashboard = new Form("dashboard");
+ dashboard.show();
+ flushSerialCalls();
+
+ final Form login = new Form("login");
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ Continuity.clear();
+ login.show();
+ throw new IllegalStateException("the session had expired");
+ }
+ });
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(320L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertTrue(login == Display.getInstance().getCurrent(),
+ "the rollback put the pre-restore screen back over the login form the show "
+ + "callback had just chosen, returning the signed-out user to the "
+ + "previous account's screen; showing "
+ + Display.getInstance().getCurrent().getTitle());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A login form a route FACTORY opened survives the undo.
+ *
+ * The other half of the show-callback case, and the one the undo used to get wrong. The
+ * factory finds the account signed out, calls clear(), and opens its own login form -- all
+ * before the restore has installed anything, so sampling the display at the moment the
+ * session ended cannot separate the two. What settles it is that the restore never showed
+ * anything at all: with nothing of its own on display, there is nothing for the undo to take
+ * down.
+ */
+ @EdtTest
+ public void aScreenARouteFactoryChoseSurvivesTheUndo() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+
+ Form dashboard = new Form("dashboard");
+ dashboard.show();
+ flushSerialCalls();
+
+ final Form login = new Form("login");
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Continuity.clear();
+ login.show();
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(200L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertTrue(login == Display.getInstance().getCurrent(),
+ "the undo put the screen the restore started from back over the login form "
+ + "the route factory had just chosen, so the user is returned to the "
+ + "signed-out account's UI; showing "
+ + Display.getInstance().getCurrent().getTitle());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A factory that ends the session stops the rebuild instead of running the rest.
+ *
+ * Every later factory would run for an account that has just signed out: constructing
+ * forms, and whatever they query or write on the way. The lifecycle check in restore() runs
+ * only after restoreStack() has returned, so it can empty the stack afterwards and undoes
+ * none of that.
+ */
+ @EdtTest
+ public void aFactoryThatEndsTheSessionStopsTheRebuild() {
+ final List built = new ArrayList();
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ built.add(url);
+ if ("/orders".equals(url)) {
+ // The account is signed out. This is the decision a route factory is
+ // entitled to make, and the reason the lifecycle machinery exists.
+ Continuity.clear();
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders", "/orders/17", "/orders/17/pay"))
+ .setDeviceId("some-other-device")
+ .setSequence(190L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertEquals(1, built.size(),
+ "the rebuild ran " + built + " -- every factory after the one that signed out "
+ + "was invoked for that account, and emptying the stack afterwards "
+ + "undoes none of what they did");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A state that expires while the user is deciding is refused when the listener hands it back.
+ *
+ * dispatch() and getRestorableState() both check maxAge, and neither is the last word. The
+ * documented flow is that a listener returns false, prompts, and calls restore(state) when the
+ * user accepts -- and the deciding is exactly the time that passes. An expired checkout or
+ * booking hold is what maxAge exists to refuse, and it was applied, persisted and acknowledged
+ * because this door did not ask.
+ */
+ @EdtTest
+ public void aStateThatExpiredWhileTheUserDecidedIsRefused() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ try {
+ AppState offered = new AppState()
+ .setPayload(payloadWith("a checkout hold"))
+ .setDeviceId("some-other-device").setSequence(420L)
+ .setTimestamp(System.currentTimeMillis());
+
+ // Fresh when it arrives.
+ Continuity.setMaxAge(60000L);
+ Continuity.deliver(offered);
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(), "the arrival was not offered at all");
+
+ // The user takes their time, and the hold expires while the prompt is up.
+ Continuity.setMaxAge(1L);
+ pause(30L);
+
+ assertFalse(Continuity.restore(offered), "an expired state reported a shown form");
+ flushSerialCalls();
+ assertNull(provider.restored,
+ "an expired checkout hold was handed to the provider, applied and "
+ + "acknowledged, though maxAge exists to refuse exactly that");
+ assertNull(Continuity.getRestorableState(),
+ "the expired state is still on offer, so the application will be handed it "
+ + "again");
+ } finally {
+ Continuity.setMaxAge(0L);
+ }
+ }
+
+ /**
+ * An out-of-order arrival from the same device does not move the user backward.
+ *
+ * Replacing a same-origin offer is supersession, and supersession has a direction. The
+ * comment said the newer sequence is the one worth showing and nothing checked: arrivals do
+ * not necessarily land in the order they were sent, so a delayed sequence 10 landing after 11
+ * replaced it. admit() has this check, but the pre-enable path does not go through admit() --
+ * the states a synced-store listener's seam collects before enable() arrive here unordered,
+ * and both copies have already been claimed from the port.
+ */
+ @EdtTest
+ public void anOutOfOrderArrivalDoesNotMoveTheUserBackward() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+
+ Continuity.parkForTest(new AppState()
+ .setPayload(payloadWith("the newer work"))
+ .setDeviceId("phone").setSequence(11L)
+ .setTimestamp(System.currentTimeMillis()));
+ // The delayed one lands second.
+ Continuity.parkForTest(new AppState()
+ .setPayload(payloadWith("the older work"))
+ .setDeviceId("phone").setSequence(10L)
+ .setTimestamp(System.currentTimeMillis()));
+
+ AppState onOffer = Continuity.getRestorableState();
+ assertNotNull(onOffer, "nothing is on offer");
+ assertEquals(11L, onOffer.getSequence(),
+ "a delayed older state from the same device replaced the newer one, so the user "
+ + "is moved backward and the newer continuation is gone");
+
+ // And a genuinely newer one still supersedes, or this guard would freeze the slot.
+ Continuity.parkForTest(new AppState()
+ .setPayload(payloadWith("newer still"))
+ .setDeviceId("phone").setSequence(12L)
+ .setTimestamp(System.currentTimeMillis()));
+ assertEquals(12L, Continuity.getRestorableState().getSequence(),
+ "a newer state no longer supersedes, so the slot is stuck on the first arrival");
+ }
+
+ /**
+ * A screen chosen after the session ended keeps its stack entry.
+ *
+ * A callback that ends the session and then goes somewhere -- clear() and then
+ * navigate("/login"), the ordinary shape of a logout discovered mid-restore -- has already
+ * replaced the stack. Emptying it then removed the login entry too, and the display guard
+ * kept the login FORM, so getCurrent() showed it while Navigation.getCurrent() was null and
+ * back() had nothing to go back to.
+ */
+ @EdtTest
+ public void aScreenChosenAfterTheSessionEndedKeepsItsStackEntry() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ if ("/orders/17".equals(url)) {
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ if (Continuity.isEnabled()) {
+ Continuity.clear();
+ Navigation.navigate("/login");
+ }
+ }
+ });
+ }
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device").setSequence(460L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertNotNull(Navigation.getCurrent(),
+ "the stack was emptied along with the restoration's own entries, so the login "
+ + "screen is showing with no history behind it and back() has nothing");
+ assertEquals("/login", Navigation.getCurrent().getPath(),
+ "the stack does not name the screen the logout chose; top is "
+ + Navigation.getCurrent().getPath());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A route-only arrival whose factory redirects is settled, not parked for ever.
+ *
+ * restoreStack() returns false when it leaves a factory's redirect alone, and false used
+ * to read as "nothing happened": a state with routes and no payload took the failure branch,
+ * was parked, held relay publication, and was offered again after every launch to redirect
+ * again. The application DID handle it -- by going somewhere else.
+ */
+ @EdtTest
+ public void aRouteOnlyArrivalWhoseFactoryRedirectsIsSettled() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ final boolean[] redirected = new boolean[1];
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ if ("/orders/17".equals(url) && !redirected[0]) {
+ redirected[0] = true;
+ Navigation.navigate("/orders");
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ AppState routeOnly = new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device").setSequence(440L)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.restore(routeOnly);
+ flushSerialCalls();
+ assertTrue(redirected[0], "the factory never redirected, so this tests nothing");
+
+ assertNotNull(Continuity.readSeenForTest().get("some-other-device"),
+ "the arrival was never settled, so it stays parked, holds relay publication, "
+ + "and is offered again after every launch to redirect again");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A factory that redirects stops the rebuild before the next factory runs.
+ *
+ * The comparison sat after the whole loop, so every later factory still constructed its
+ * screen and touched whatever the application keeps behind it -- an unavailable parent
+ * redirecting to a safe list while its child factories go on reading the record that is
+ * unavailable -- and all of it was then discarded in favour of the redirect.
+ */
+ @EdtTest
+ public void aFactoryRedirectStopsTheRebuildBeforeTheNextFactoryRuns() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ final List built = new ArrayList();
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ built.add(url);
+ if ("/orders/17".equals(url) && built.size() == 1) {
+ Navigation.navigate("/orders");
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17", "/orders/17/pay"))
+ .setDeviceId("some-other-device").setSequence(450L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertFalse(built.contains("/orders/17/pay"),
+ "a factory ran after an earlier one had already redirected, so it built a "
+ + "screen and touched whatever is behind it for nothing: " + built);
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A redirect started inside a route FACTORY wins over the stack being rebuilt.
+ *
+ * A factory is application code and may redirect -- an expired detail page sending the user
+ * to a list. It does so before restoreStack() has installed anything, so the rebuild replaced
+ * both its stack entry and its screen with the ones being restored. The show-callback twin of
+ * this was fixed earlier; this one happens a step sooner.
+ */
+ @EdtTest
+ public void aRedirectStartedInsideAFactoryWins() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ final boolean[] redirected = new boolean[1];
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ if ("/orders/17".equals(url) && !redirected[0]) {
+ redirected[0] = true;
+ // "That order is gone." Redirect from inside the factory itself -- and still
+ // answer with a form, because a factory that returns null is already covered
+ // by the empty-rebuild check and would make this test pass either way. That
+ // is what a first version of it did.
+ Navigation.navigate("/orders");
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device").setSequence(430L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+ assertTrue(redirected[0], "the factory never redirected, so this tests nothing");
+
+ assertNotNull(Navigation.getCurrent(), "the stack was left empty");
+ assertEquals("/orders", Navigation.getCurrent().getPath(),
+ "the rebuild replaced the screen the factory redirected to; top is "
+ + Navigation.getCurrent().getPath());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A local checkpoint whose restore FAILED survives the next checkpoint.
+ *
+ * dispatch() already keeps an arrival whose restore failed; the application-driven path
+ * did not. So a cold start whose provider threw -- a dependency not up yet, which is the
+ * transient this failure branch exists for -- left the on-device checkpoint as the only copy,
+ * and the next navigation checkpointed the fallback screen over it. The draft the user was
+ * promised is gone at exactly the moment "restore, or else begin" is meant to protect it.
+ */
+ @EdtTest
+ public void aLocalCheckpointWhoseRestoreFailedSurvivesTheNextCheckpoint() {
+ // A provider that saves a draft, then refuses to restore it once, then answers empty --
+ // an application whose own data has not loaded yet.
+ final boolean[] refuse = new boolean[] {false};
+ final Map toSave = new HashMap();
+ toSave.put("draft", "half a letter");
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap(toSave);
+ }
+
+ public void restoreState(Map payload) {
+ if (refuse[0]) {
+ throw new IllegalStateException("the store is not open yet");
+ }
+ }
+ });
+ Continuity.checkpoint();
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(), "the fixture stored nothing");
+
+ // The restore fails, and the application carries on with an empty provider.
+ refuse[0] = true;
+ toSave.clear();
+ assertFalse(Continuity.restore(), "the refusing restore reported a shown form");
+ flushSerialCalls();
+
+ // Ordinary work continues and checkpoints, as it must.
+ Continuity.checkpoint();
+ flushSerialCalls();
+
+ AppState still = Continuity.getRestorableState();
+ assertNotNull(still, "nothing is offered at all after the failed restore");
+ assertEquals("half a letter", still.getPayload().get("draft"),
+ "the checkpoint that followed the failed restore overwrote the only copy of the "
+ + "payload, so the retry this failure path exists for has nothing left "
+ + "to retry");
+ }
+
+ /**
+ * A listener that holds two arrivals in a row does not lose the first.
+ *
+ * Returning false keeps an arrival on offer -- the documented prompt-then-restore pattern.
+ * Do it for device A and then for device B before A is resolved and B replaced A in the slot,
+ * so A could never be offered again in that run.
+ *
+ * The first fix for this forgot A's admission mark, so a REDELIVERY could bring it back.
+ * That bet on a delivery which is not coming: the off-EDT callback claims what it queues --
+ * it has to, the decision is made later on the EDT and the port is owed an answer now -- so a
+ * conforming bridge is entitled to drop its copy the moment it hands over. A is kept on the
+ * shelf instead, and comes back with nothing redelivering it.
+ */
+ @EdtTest
+ public void aListenerHoldingTwoArrivalsDoesNotLoseTheFirst() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(true);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ // "I will prompt and call restore() when the user accepts."
+ return false;
+ }
+ });
+
+ AppState fromPhone = new AppState()
+ .setPayload(payloadWith("from the phone"))
+ .setDeviceId("phone").setSequence(1L)
+ .setTimestamp(System.currentTimeMillis());
+ AppState fromTablet = new AppState()
+ .setPayload(payloadWith("from the tablet"))
+ .setDeviceId("tablet").setSequence(1L)
+ .setTimestamp(System.currentTimeMillis());
+
+ Continuity.deliver(fromPhone);
+ flushSerialCalls();
+ Continuity.deliver(fromTablet);
+ flushSerialCalls();
+
+ assertNull(Continuity.readSeenForTest().get("phone"),
+ "a durable mark was left for a held state that was never completed");
+
+ AppState onOffer = Continuity.getRestorableState();
+ assertNotNull(onOffer, "nothing is on offer at all");
+ assertEquals("tablet", onOffer.getDeviceId(),
+ "the newer arrival is not the one on offer");
+
+ // The user deals with the tablet's. NOTHING redelivers the phone's -- no second Handoff,
+ // no relay read -- and it still has to come back, because nothing ever dealt with it.
+ Continuity.acknowledge(onOffer);
+ AppState back = Continuity.getRestorableState();
+ assertNotNull(back,
+ "the state the listener was holding for the phone was dropped when the tablet's "
+ + "replaced it in the slot, and no redelivery is coming for it: the "
+ + "callback claimed it off-EDT, so the port has let go of its copy");
+ assertEquals("phone", back.getDeviceId(),
+ "the phone's held state was not what came back once the tablet's was settled");
+ assertEquals("from the phone", back.getPayload().get("note"),
+ "something with the phone's device id came back, but not its payload");
+ }
+
+ /**
+ * An offer replaced by one from ANOTHER device is kept, not dropped.
+ *
+ * The slot holds one arrival, which is right, and replacing it is right when the two come
+ * from the same device -- that is supersession. Two different devices are not that: with
+ * automatic restoration off both can be dispatched before the application calls restore(),
+ * and the second overwrote the first.
+ *
+ * The displaced arrival goes to the shelf and is promoted when the slot empties. This test
+ * pins that it comes back WITHOUT a redelivery, which is the part the earlier mark-forgetting
+ * fix could not provide -- see the sibling test above for why no redelivery is coming.
+ */
+ @EdtTest
+ public void anOfferReplacedByAnotherDeviceIsKeptRatherThanDropped() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ AppState first = new AppState()
+ .setPayload(payloadWith("from the phone"))
+ .setDeviceId("phone").setSequence(1L)
+ .setTimestamp(System.currentTimeMillis());
+ AppState second = new AppState()
+ .setPayload(payloadWith("from the tablet"))
+ .setDeviceId("tablet").setSequence(1L)
+ .setTimestamp(System.currentTimeMillis());
+
+ Continuity.deliver(first);
+ flushSerialCalls();
+ Continuity.deliver(second);
+ flushSerialCalls();
+
+ AppState onOffer = Continuity.getRestorableState();
+ assertNotNull(onOffer, "nothing is on offer at all");
+ assertEquals("tablet", onOffer.getDeviceId(),
+ "the newer arrival is not the one on offer");
+
+ // The phone's state was displaced from the slot. It must not ALSO be remembered as
+ // handled, because nothing ever handled it.
+ assertNull(Continuity.readSeenForTest().get("phone"),
+ "a durable mark was left for a state that was never completed");
+
+ Continuity.acknowledge(onOffer);
+ AppState back = Continuity.getRestorableState();
+ assertNotNull(back,
+ "the phone's state was dropped when the tablet's took the slot, so it is lost for "
+ + "the rest of the process");
+ assertEquals("phone", back.getDeviceId(), "the phone's state was not what came back");
+ assertEquals("from the phone", back.getPayload().get("note"),
+ "something with the phone's device id came back, but not its payload");
+
+ // And settling THAT one leaves nothing from elsewhere behind: the shelf is a hold for
+ // work that arrived, not a queue that grows. What may still be offered is this device's
+ // own stored checkpoint, which getRestorableState() falls through to and which is not
+ // what this is about.
+ Continuity.acknowledge(back);
+ AppState after = Continuity.getRestorableState();
+ assertTrue(after == null
+ || (!"phone".equals(after.getDeviceId())
+ && !"tablet".equals(after.getDeviceId())),
+ "an arrival from another device is still on offer after both were settled");
+ }
+
+ private static Map payloadWith(String note) {
+ Map payload = new HashMap();
+ payload.put("note", note);
+ return payload;
+ }
+
+ /**
+ * A state whose routes are ALL unusable still commits, on the payload-only path.
+ *
+ * The sibling of the test below, and the exit the reconciliation did not cover.
+ * usableRoutes() drops every route this device cannot store, so a state carrying a good
+ * payload and nothing storable beside it takes the payload-only return -- which reached
+ * commit() before the filtered set was applied. persist() then threw on the original
+ * oversized route every time, so the arrival stayed parked, was re-applied on every retry,
+ * and held every relay publication behind it.
+ */
+ @EdtTest
+ public void aStateWhoseRoutesAreAllUnusableStillCommits() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ StringBuilder huge = new StringBuilder("/");
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ Map payload = new HashMap();
+ payload.put("draft", "worth keeping");
+ AppState arriving = new AppState()
+ .setPayload(payload)
+ .setDeviceId("some-other-device")
+ .setSequence(410L)
+ .setTimestamp(System.currentTimeMillis());
+ // Unchecked, because a remote document is accepted unchecked on purpose -- which is how
+ // an unstorable route reaches this device at all.
+ arriving.setRoutesUnchecked(java.util.Arrays.asList(huge.toString()));
+
+ Continuity.restore(arriving);
+ flushSerialCalls();
+
+ assertEquals("worth keeping", provider.restored.get("draft"),
+ "the payload never reached the provider, so this test is about nothing");
+ assertNotNull(Continuity.readSeenForTest().get("some-other-device"),
+ "the arrival was never acknowledged: commit() persisted the oversized route the "
+ + "filter had already dropped, so it stays parked, is re-applied on every "
+ + "retry, and holds every relay publication behind it");
+ }
+
+ /**
+ * A route this device cannot store is dropped from what gets COMMITTED too, not only from
+ * what gets rebuilt.
+ *
+ * usableRoutes() filtered the copy handed to restoreStack() and left the oversized route in
+ * the state, so commit() went on to persist the original and externalize() threw on it every
+ * time. The arrival stayed parked -- re-applied on every retry, with every relay publication
+ * held behind it -- for ever.
+ */
+ @EdtTest
+ public void aRouteTooLongToStoreIsDroppedFromWhatIsCommitted() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ Navigation.setDispatcher(new FakeLongPathDispatcher());
+ try {
+ StringBuilder huge = new StringBuilder("/");
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ Map payload = new HashMap();
+ payload.put("draft", "worth keeping");
+ AppState arriving = new AppState()
+ .setPayload(payload)
+ .setDeviceId("some-other-device")
+ .setSequence(191L)
+ .setTimestamp(System.currentTimeMillis());
+ // Unchecked, because a remote document is accepted unchecked on purpose -- which is
+ // exactly how an unstorable route reaches this device in the first place.
+ arriving.setRoutesUnchecked(java.util.Arrays.asList("/orders", huge.toString()));
+
+ Continuity.restore(arriving);
+ flushSerialCalls();
+
+ assertNotNull(Continuity.readSeenForTest().get("some-other-device"),
+ "the arrival was never acknowledged, so commit() failed on the route that "
+ + "usableRoutes() had already dropped: it stays parked, is re-applied "
+ + "on every retry, and holds every relay publication behind it");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A cold-launch arrival the port is holding is dropped by a clear() that precedes enable().
+ *
+ * The sibling of the disable() case, and the door it left open. A Handoff that
+ * cold-launches a logged-out app reaches the port before anything has installed a callback
+ * and is held there, so clearing the framework's own parked slot cleared nothing that
+ * existed -- and the enable() that came with the later login drained the port and restored
+ * the pre-logout payload and routes into the next account.
+ *
+ * Not done by recording a choice: clear() is a logout, not "I do not want continuity",
+ * and an arrival that comes AFTER it is not from before it and must still be held for the
+ * enable() that is coming.
+ */
+ @EdtTest
+ public void aHeldArrivalIsDroppedByAClearThatPrecedesEnable() {
+ HoldingBridge holding = new HoldingBridge();
+ holding.pending = StateCodec.toMap(fromElsewhere("from before the logout", 300L));
+ Continuity.setBridge(holding);
+
+ // Never enabled -- logged out at launch -- and the app wipes state.
+ Continuity.clear();
+ assertNull(holding.pending,
+ "the port is still holding the pre-logout arrival, so the enable() that comes "
+ + "with the login will drain it into the next account");
+
+ Continuity.setStateProvider(new RecordingProvider());
+ flushSerialCalls();
+ assertNull(Continuity.getRestorableState(),
+ "the arrival from before the clear() was restored after it");
+
+ // And an arrival AFTER the clear is still held for the enable that is coming, which is
+ // what makes this a drain rather than a policy change.
+ Continuity.reset();
+ Storage.getInstance().clearStorage();
+ HoldingBridge later = new HoldingBridge();
+ Continuity.setBridge(later);
+ Continuity.clear();
+ later.pending = StateCodec.toMap(fromElsewhere("after the clear", 301L));
+ ContinuityCallback c = Continuity.callbackForTest();
+ assertFalse(c.continuationReceived(Continuity.getActivityType(), later.pending),
+ "an arrival that came after the clear was claimed and dropped, so the enable() "
+ + "about to happen has nothing to deliver");
+ }
+
+ /**
+ * A redirect performed by the restored form's show callback is what gets checkpointed.
+ *
+ * routeStackChanged() returns early while a restore is being applied -- it has to, or the
+ * rebuild checkpoints and republishes the state it is applying and the two devices bounce it
+ * back and forth. But the restored form's show callback is application code and may navigate:
+ * a screen that redirects to a newer one, an expired detail page sending the user to a list.
+ * Both notifications for that navigation land inside the window and are dropped, so the
+ * checkpoint recorded the routes that ARRIVED instead of the ones the user is on -- and a
+ * process death before the next one restores the screen the application redirected away
+ * from.
+ */
+ @EdtTest
+ public void aRedirectDuringTheRebuildIsWhatGetsCheckpointed() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ final boolean[] redirected = new boolean[1];
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ if ("/orders/17".equals(url)) {
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ if (!redirected[0]) {
+ // "That order is gone -- here is the list instead."
+ redirected[0] = true;
+ Navigation.navigate("/orders");
+ }
+ }
+ });
+ }
+ return f;
+ }
+ });
+ try {
+ Continuity.restore(new AppState()
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(370L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+ flushSerialCalls();
+ assertTrue(redirected[0], "the show callback never redirected, so this tests nothing");
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored, "nothing was stored at all");
+ assertTrue(stored.getRoutes().contains("/orders"),
+ "the checkpoint kept the routes that ARRIVED (" + stored.getRoutes() + ") "
+ + "rather than the ones the application redirected to, so a process "
+ + "death restores the screen it sent the user away from");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * An unrecognised relay object is a failed read; a bare empty one is still an empty relay.
+ *
+ * fromMap answers null for a document it recognises nothing in, and null means "the relay
+ * holds nothing" to the code that reads a fetch -- so the publisher is released and a local
+ * checkpoint overwrites a document this device never read. A 2xx carrying
+ * {@code {"error":"temporarily unavailable"}} is the shape that does it.
+ *
+ * The empty half matters as much: a bare {@code {}} is a plausible way for an
+ * endpoint to say it holds nothing, and refusing it would leave such an endpoint unable to
+ * publish anything ever.
+ */
+ @EdtTest
+ public void anUnrecognisedRelayObjectIsAFailedReadAndAnEmptyOneIsNot() throws Exception {
+ try {
+ StateCodec.fromJson("{\"error\":\"temporarily unavailable\"}");
+ fail("an object carrying fields none of which are ours was read as an empty relay, "
+ + "so a checkpoint is published over work this device could not read");
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ // And the empty object still means "nothing here", which an endpoint is entitled to say.
+ assertNull(StateCodec.fromJson("{}"),
+ "a bare empty object was refused, so an endpoint that answers that way for "
+ + "\"none\" can never publish anything");
+ }
+
+ /**
+ * A relay document with no origin is a failed read, so the publisher stays held.
+ *
+ * An origin is the key every mark and every dedup decision is made against, so admit() can
+ * only log such a state and drop it. Counting the read as successful anyway is what does the
+ * damage: it clears the unread flag and releases a checkpoint queued during the GET, and that
+ * publish overwrites the relay's only copy of remote work nothing here could read.
+ */
+ @EdtTest
+ public void aRelayDocumentWithNoOriginIsAFailedRead() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ final java.util.concurrent.atomic.AtomicInteger reads =
+ new java.util.concurrent.atomic.AtomicInteger();
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ published.add(state);
+ }
+
+ public AppState fetch() {
+ reads.incrementAndGet();
+ // Recognisable -- it has a sequence -- and unusable, because it names no device.
+ return new AppState().setSequence(360L).setTimestamp(System.currentTimeMillis());
+ }
+ });
+ pause(300L);
+ flushSerialCalls();
+ assertTrue(reads.get() > 0, "the relay was never read, so this test is about nothing");
+
+ // A checkpoint queued while that read was outstanding must NOT go out: the document on the
+ // relay is remote work this build could not read, and publishing over it destroys it.
+ int before = published.size();
+ Continuity.checkpoint();
+ pause(300L);
+ flushSerialCalls();
+ assertEquals(before, published.size(),
+ "a checkpoint was published over a relay document this build could not read, so "
+ + "the other device's only copy is gone");
+ }
+
+ /**
+ * A continuation held by BOTH the port and the framework is delivered once.
+ *
+ * A port may retain the same continuation its pre-enable callback declined, so both it and
+ * this class can hold a copy. Enabling re-offers the port's and drains ours, and the drained
+ * one used to go straight to dispatch -- past the (origin, sequence) deduplication that
+ * admission exists for -- so the listeners and the provider ran twice on one arrival.
+ *
+ * The comment that justified parking said the two copies dedup at admission. They only do
+ * if they both go through it.
+ */
+ @EdtTest
+ public void aContinuationHeldByBothThePortAndTheFrameworkIsDeliveredOnce() {
+ final java.util.concurrent.atomic.AtomicInteger restores =
+ new java.util.concurrent.atomic.AtomicInteger();
+ HoldingBridge holding = new HoldingBridge();
+ Continuity.setBridge(holding);
+ SyncedStoreListener listener = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ try {
+ // The sync-only window: a seam exists, continuity does not.
+ SyncedStore.addChangeListener(listener);
+ Map info = StateCodec.toMap(fromElsewhere("held twice", 350L));
+ holding.pending = info;
+
+ // The framework takes and parks it, and the port keeps its own copy because the
+ // answer was a decline -- which is exactly what ContinuityBridge permits.
+ ContinuityCallback c = Continuity.callbackForTest();
+ c.continuationReceived(Continuity.getActivityType(), info);
+ flushSerialCalls();
+
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ restores.incrementAndGet();
+ }
+ });
+ for (int i = 0; i < 20 && restores.get() == 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+
+ assertEquals(1, restores.get(),
+ "one arrival was restored " + restores.get() + " times, because the copy this "
+ + "class held went straight to dispatch and never met the "
+ + "(origin, sequence) check that would have recognised the port's");
+ } finally {
+ SyncedStore.removeChangeListener(listener);
+ }
+ }
+
+ /**
+ * An arrival is bound to the generation it ARRIVED in, not the one the decision runs in.
+ *
+ * Every hop between the activity and the decision is a queue, and a logout already sitting
+ * on the event queue runs first. A generation read after those hops is the one AFTER the
+ * logout, so every later check passes and the previous account's state is restored and
+ * persisted by a session that promised nothing from before it survives -- clear() deliberately
+ * leaves continuity enabled, so nothing else refuses it.
+ *
+ * The iOS port used to add a hop of its own before the framework saw the arrival, which is
+ * why it now hands over directly and lets the framework marshal.
+ */
+ @EdtTest
+ public void anArrivalIsBoundToTheGenerationItArrivedIn() {
+ Continuity.enable();
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ ContinuityCallback callback = Continuity.callbackForTest();
+ Map info = StateCodec.toMap(fromElsewhere("the previous account", 340L));
+
+ // From a BACKGROUND thread, which is the only shape that exercises this: the decision is
+ // queued, so it runs after the logout, and the generation it compares against has to have
+ // been captured when the activity arrived. Called on the event thread instead, the
+ // decision runs inline before the logout and deliver()'s own guard covers it -- a first
+ // version of this test did exactly that and passed with the check removed.
+ final ContinuityCallback c = callback;
+ final Map arriving = info;
+ final java.util.concurrent.CountDownLatch queued =
+ new java.util.concurrent.CountDownLatch(1);
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ Continuity.clear();
+ }
+ });
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ c.continuationReceived(Continuity.getActivityType(), arriving);
+ queued.countDown();
+ }
+ }, "continuity arrival").start();
+ for (int i = 0; i < 40 && queued.getCount() > 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(0L, queued.getCount(), "the arrival never reached the framework");
+ flushSerialCalls();
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "a continuation that arrived before the logout was restored by the session after "
+ + "it, because the generation was read once the logout had already run");
+ assertNull(Continuity.readSeenForTest().get("some-other-device"),
+ "it was marked durably too, so the origin's real states are refused after a "
+ + "restart as already seen");
+ }
+
+ /**
+ * A bridge that reuses its map after the call does not change what gets restored.
+ *
+ * ContinuityCallback lets a port call from any thread and says nothing about the map
+ * outliving the call, so a port that recycles one buffer per arrival -- or clears it once the
+ * call returns -- is within its rights. The off-EDT branch had already told that port the
+ * activity was CLAIMED while queuing only the reference, so the decision read whatever the
+ * bridge had put there since: a different state, or none.
+ */
+ @EdtTest
+ public void aBridgeThatReusesItsMapDoesNotChangeWhatIsRestored() {
+ Continuity.enable();
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ final ContinuityCallback callback = Continuity.callbackForTest();
+
+ // The map the "port" hands over, and then reuses for something else entirely.
+ final Map reused =
+ StateCodec.toMap(fromElsewhere("what was actually sent", 390L));
+ final java.util.concurrent.CountDownLatch returned =
+ new java.util.concurrent.CountDownLatch(1);
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ callback.continuationReceived(Continuity.getActivityType(), reused);
+ // Recycled the instant the call returns, exactly as a pooling bridge would.
+ reused.clear();
+ reused.put("device", "somebody-else");
+ reused.put("seq", "999");
+ returned.countDown();
+ }
+ }, "continuity recycling bridge").start();
+
+ for (int i = 0; i < 40 && returned.getCount() > 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(0L, returned.getCount(), "the bridge thread never returned");
+ for (int i = 0; i < 20 && provider.restored == null; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+
+ assertNotNull(provider.restored,
+ "the claimed arrival was never delivered -- the queued decision read a map the "
+ + "bridge had already emptied");
+ assertEquals("what was actually sent", provider.restored.get("note"),
+ "the payload delivered was not the one handed over, because the decision read "
+ + "the bridge's buffer after it had been recycled");
+ }
+
+ /**
+ * A background arrival before enable() is claimed AND kept, and the enable() delivers it.
+ *
+ * This asserted the opposite -- that the callback declines rather than claims -- and that
+ * was right for a design where the PORT held the arrival. It stopped being right once the
+ * decline could not reach a port at all: after enable() has installed the seam there is no
+ * later install to re-offer anything, so a decline stranded the arrival or lost it outright
+ * with a bridge that does not retain.
+ *
+ * What must hold either way is that the arrival is not lost, and that is what this checks
+ * now: the framework claims it -- which is honest, because it then holds it itself -- and the
+ * enable() that follows a sync-only listener delivers it.
+ */
+ @EdtTest
+ public void aBackgroundArrivalBeforeEnableIsKeptAndDeliveredByTheEnable() {
+ // A sync-only application: the store listener installs the callback and continuity is
+ // deliberately NOT enabled.
+ SyncedStoreListener listener = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ SyncedStore.addChangeListener(listener);
+ try {
+ final ContinuityCallback callback = Continuity.callbackForTest();
+ final Map info =
+ StateCodec.toMap(fromElsewhere("cold-launch handoff", 310L));
+ final java.util.concurrent.atomic.AtomicBoolean claimed =
+ new java.util.concurrent.atomic.AtomicBoolean(true);
+ final java.util.concurrent.CountDownLatch done =
+ new java.util.concurrent.CountDownLatch(1);
+
+ Display.getInstance().startThread(new Runnable() {
+ public void run() {
+ claimed.set(callback.continuationReceived(Continuity.getActivityType(), info));
+ done.countDown();
+ }
+ }, "continuity background caller").start();
+
+ for (int i = 0; i < 40 && done.getCount() > 0; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertEquals(0L, done.getCount(), "the background caller never returned");
+ assertTrue(claimed.get(),
+ "the framework declined instead of taking responsibility, and after enable() "
+ + "installs the seam there is no later install to re-offer it -- so "
+ + "the arrival is stranded with the port or lost outright");
+
+ // The claim has to be honest: enabling delivers what was kept.
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ for (int i = 0; i < 20 && provider.restored == null; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertNotNull(provider.restored,
+ "the arrival was claimed and then never delivered, which is the one outcome "
+ + "a claim must not produce -- the cold-launch continuation is gone");
+ } finally {
+ SyncedStore.removeChangeListener(listener);
+ }
+ }
+
+ /**
+ * An arrival this class is holding is dropped by a disable() that comes before any enable().
+ *
+ * The sibling of the port-held case, and the door my own parking change opened.
+ * Callback.decide() parks an arrival that reaches the seam before the application has chosen
+ * -- a synced-store listener installs that seam without enabling continuity -- so a logged-out
+ * app saying "off" can have a copy here as well as at the port. Draining only the port's left
+ * this one in the slot, and enable() drains that slot on purpose, so the login restored a
+ * payload and routes that arrived before the application said it wanted none.
+ */
+ @EdtTest
+ public void anArrivalThisClassIsHoldingIsDroppedByAFirstDisable() {
+ HoldingBridge holding = new HoldingBridge();
+ Continuity.setBridge(holding);
+ SyncedStoreListener listener = new SyncedStoreListener() {
+ public void storeChanged() {
+ }
+ };
+ try {
+ // A seam without continuity, which is what parks an arrival here.
+ SyncedStore.addChangeListener(listener);
+ Map info = StateCodec.toMap(fromElsewhere("before the app chose", 380L));
+ ContinuityCallback c = Continuity.callbackForTest();
+ c.continuationReceived(Continuity.getActivityType(), info);
+ flushSerialCalls();
+
+ // "Not while I am logged out."
+ Continuity.disable();
+
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ for (int i = 0; i < 20 && provider.restored == null; i++) {
+ pause(50L);
+ flushSerialCalls();
+ }
+ assertNull(provider.restored,
+ "an arrival this class was holding survived the disable() and was restored by "
+ + "the enable() that came with the login, though disable() documents "
+ + "that arriving states are ignored");
+ } finally {
+ SyncedStore.removeChangeListener(listener);
+ }
+ }
+
+ /**
+ * A cold-launch arrival the port is already holding is dropped by the first disable().
+ *
+ * iOS parks a Handoff before init() runs and hands it over when a callback is next
+ * installed. An application that is logged out at launch, calls disable(), and enables after
+ * the login had that parked activity drained by the enable() -- when `enabled` is true again,
+ * so the callback delivered it. The choice flag never got a look in: it is read inside the
+ * callback, and no callback existed for the port to offer the arrival to.
+ */
+ @EdtTest
+ public void aParkedArrivalIsDroppedByAFirstDisable() {
+ HoldingBridge holding = new HoldingBridge();
+ holding.pending = StateCodec.toMap(fromElsewhere("parked before init()", 180L));
+ Continuity.setBridge(holding);
+
+ // Never enabled: logged out at launch, and saying so.
+ Continuity.disable();
+ assertNull(holding.pending,
+ "the port is still holding the arrival, so nothing has asked it for one and the "
+ + "enable() after the login will drain it");
+
+ Continuity.enable();
+ flushSerialCalls();
+ assertNull(Continuity.getRestorableState(),
+ "the arrival the port was holding through an explicit disable() was restored by "
+ + "the enable() that followed it");
+ }
+
+ /**
+ * A restore whose session ends mid-way empties the stack it installed, even when a route was
+ * SKIPPED.
+ *
+ * restoreStack() drops a path this build no longer registers -- deliberately, it is the
+ * tolerance that lets an old checkpoint restore what it still can -- so what it installs is a
+ * subsequence of what it was asked for, equal to it only when nothing was skipped. The abort
+ * compared the live stack against the REQUESTED routes, so one skipped path made it answer
+ * "the application has navigated" for a stack the application had not touched, and the
+ * restored entries were left in Navigation: getCurrent() and back() disagreeing with what is
+ * on screen, and those entries exposed again if continuity is re-enabled.
+ */
+ @EdtTest
+ public void aSessionEndedMidRestoreEmptiesTheStackEvenWhenARouteWasSkipped() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ final Form dashboard = new Form("dashboard");
+ dashboard.show();
+ flushSerialCalls();
+
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ if ("/gone".equals(url)) {
+ // A route this build no longer registers. Skipped, not fatal.
+ return null;
+ }
+ Form f = new Form();
+ f.setTitle(url);
+ if ("/orders/17".equals(url)) {
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ // Ends the session and goes NOWHERE, which is the case that separates
+ // "the application navigated" from "the restore's own stack".
+ Continuity.disable();
+ }
+ });
+ }
+ return f;
+ }
+ });
+ try {
+ Map payload = new HashMap();
+ payload.put("draft", "the previous account's");
+ Continuity.restore(new AppState()
+ .setPayload(payload)
+ .setRoutes(java.util.Arrays.asList("/orders", "/gone", "/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(171L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertTrue(Navigation.getStack().isEmpty(),
+ "the restore's own entries were left in Navigation after the session ended, "
+ + "because one skipped route made the live stack unequal to the routes "
+ + "that were asked for: " + Navigation.getStack());
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A login form the logout callback put up is not replaced by the screen the restore started
+ * from.
+ *
+ * A restored route's show callback finds the session expired, calls clear(), and shows a
+ * login form before returning. The undo then re-showed the screen the restore had started
+ * from, which is the signed-out account's own UI -- the exact thing the callback replaced it
+ * to avoid. Both happen inside restoreStack(), so from outside they look identical; the
+ * instant the session ended is where they separate.
+ */
+ @EdtTest
+ public void aScreenTheLogoutCallbackChoseSurvivesTheUndo() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ final Form dashboard = new Form("dashboard");
+ dashboard.show();
+ flushSerialCalls();
+
+ final Form login = new Form("login");
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ // The session has expired. Sign out, and put the user where they now
+ // belong -- which is the whole point of doing this from the callback.
+ Continuity.clear();
+ login.show();
+ }
+ });
+ return f;
+ }
+ });
+ try {
+ Map payload = new HashMap();
+ payload.put("draft", "the previous account's");
+ Continuity.restore(new AppState()
+ .setPayload(payload)
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(170L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ assertTrue(login == Display.getInstance().getCurrent(),
+ "the undo put the screen the restore started from back over the login form "
+ + "the logout callback had just chosen, so the user is returned to "
+ + "the signed-out account's UI; showing "
+ + Display.getInstance().getCurrent().getTitle());
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A document in an encoding this build does not know is a failed read, not untagged data.
+ *
+ * An unknown encoding is not an unknown FIELD. A field this codec does not know is ignored
+ * on purpose -- that is how a newer sender goes on talking to this build. An encoding marker
+ * changes how the fields it does know must be read, so falling back to "untagged" handed the
+ * provider every encoded scalar as a raw string, and the state was then persisted and
+ * acknowledged: the origin's high-water mark advanced, so the correctly encoded document was
+ * never offered again, not even after the receiving app was upgraded to understand it.
+ */
+ @EdtTest
+ public void aDocumentInAnUnknownEncodingIsAFailedRead() throws Exception {
+ try {
+ AppState s = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\","
+ + "\"enc\":\"2\",\"payload\":{\"n\":\"i:5\"}}");
+ fail("a document in encoding \"2\" was read as untagged"
+ + (s != null ? ", so the provider is handed " + s.getPayload().get("n")
+ + " where the sender wrote the number 5" : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ // The two encodings this build DOES understand still work, or the guard would refuse
+ // every document there is.
+ AppState tagged = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\","
+ + "\"enc\":\"1\",\"payload\":{\"n\":\"i:5\"}}");
+ assertEquals(Integer.valueOf(5), tagged.getPayload().get("n"),
+ "a tagged document stopped decoding");
+ AppState legacy = StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\","
+ + "\"payload\":{\"n\":\"plain\"}}");
+ assertEquals("plain", legacy.getPayload().get("n"),
+ "a document with no encoding marker was refused, so every older sender is cut off");
+ }
+
+ /**
+ * A route rebuild that THREW keeps the state pending, even when the payload applied.
+ *
+ * A throw is a different thing from routes that would not rebuild, and the two were
+ * collapsed. The orderly case is safe to acknowledge -- this build no longer registers those
+ * routes, they will not start working next launch, and the payload already worked on this
+ * one. A throw is the transient breakage a provider that throws gets, and with the payload
+ * taken the failure branch did not fire at all: the state was persisted and acknowledged, so
+ * the relay's only other copy was released while the user is not on the restored screen, and
+ * the next navigation overwrites both.
+ */
+ @EdtTest
+ public void aRouteRebuildThatThrewKeepsTheStatePendingEvenWhenThePayloadApplied() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ // The form's SHOW is what throws, not the dispatcher: restoreStack() catches a
+ // dispatcher failure per path and carries on, so a dispatcher that throws just leaves
+ // nothing to rebuild and returns false -- the orderly case, which is acknowledged on
+ // purpose. The throw that reaches capture()'s caller comes from show(), which
+ // restoreStack() deliberately rethrows after undoing the stack and the screen.
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ Form f = new Form();
+ f.setTitle(url);
+ f.addShowListener(new com.codename1.ui.events.ActionListener() {
+ public void actionPerformed(com.codename1.ui.events.ActionEvent evt) {
+ throw new IllegalStateException("the restored screen could not open");
+ }
+ });
+ return f;
+ }
+ });
+ try {
+ Map payload = new HashMap();
+ payload.put("draft", "half a sentence");
+ AppState arriving = new AppState()
+ .setPayload(payload)
+ .setRoutes(java.util.Arrays.asList("/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(160L)
+ .setTimestamp(System.currentTimeMillis());
+
+ assertFalse(Continuity.restore(arriving), "a rebuild that threw reported a shown form");
+ flushSerialCalls();
+
+ // The payload DID apply, which is the precondition: this is the combination that
+ // slipped past the failure branch.
+ assertNotNull(provider.restored, "the fixture never applied the payload");
+
+ assertNull(Continuity.readSeenForTest().get("some-other-device"),
+ "a state whose route rebuild threw was acknowledged durably because its "
+ + "payload applied, so the relay's only other copy is released while "
+ + "the user is not on the restored screen");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * An oversized title is refused at setTitle(), not at the next checkpoint.
+ *
+ * capture() builds the AppState through the validating setter, and nothing catches what it
+ * throws: `dirty` is assigned after capture() returns, so it stayed set and every later
+ * navigation retried the same failing capture. Nothing was stored or published again, and the
+ * application was never told why.
+ *
+ * The same shape as a local route too long to store, which reaches capture() through the
+ * navigation stack and cannot be refused at Navigation -- a general routing API must not
+ * reject a path because continuity could not store it. That one is dropped by the same filter
+ * the inbound path uses, and the checkpoint still happens.
+ */
+ @EdtTest
+ public void anOversizedTitleIsRefusedAtTheCallAndALongRouteDoesNotStopCheckpoints() {
+ Continuity.setStateProvider(new RecordingProvider());
+ StringBuilder huge = new StringBuilder();
+ for (int i = 0; i < 70000; i++) {
+ huge.append('x');
+ }
+ try {
+ Continuity.setTitle(huge.toString());
+ fail("an oversized title was accepted, so it surfaces from the next checkpoint "
+ + "instead -- where nothing catches it and every later navigation retries "
+ + "the same failing capture");
+ } catch (IllegalArgumentException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+ assertNull(Continuity.getTitle(), "the refused title was stored anyway");
+
+ // And the route half, which has no earlier place to be refused.
+ Navigation.setDispatcher(new FakeLongPathDispatcher());
+ try {
+ Navigation.navigate("/" + huge);
+ Continuity.checkpoint();
+ flushSerialCalls();
+
+ AppState stored = Continuity.getRestorableState();
+ assertNotNull(stored,
+ "a local route too long to store ended every checkpoint this process would "
+ + "make: capture() threw, dirty stayed set, and nothing was written "
+ + "or published again");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A disable() before any enable() is still an answer, and an arrival during it is dropped.
+ *
+ * The gap the previous fix left. An application that enables continuity only after a login
+ * and calls disable() while logged out was leaving the flag unset, because disable() returned
+ * early when there was nothing to turn off -- so an arrival during that interval was read as
+ * a pre-enable cold-launch arrival: declined, retained by the port, and delivered by the
+ * enable() that came with the login. Saying "no" before saying anything else is still saying
+ * it.
+ */
+ @EdtTest
+ public void aDisableBeforeAnyEnableStillDropsAnArrival() {
+ // Never enabled. This is the whole point: disable() has nothing to turn off here.
+ Continuity.disable();
+ ContinuityCallback callback = Continuity.callbackForTest();
+
+ Map info = StateCodec.toMap(fromElsewhere("during the logged-out spell", 92L));
+ assertTrue(callback.continuationReceived(Continuity.getActivityType(), info),
+ "the callback declined an arrival after an explicit disable() that happened to be "
+ + "the application's FIRST word, so the port holds it and the enable() "
+ + "that comes with the login restores it");
+
+ Continuity.enable();
+ assertNull(Continuity.getRestorableState(),
+ "the arrival from the disabled interval was delivered after all");
+ }
+
+ /**
+ * A platform continuation gets the same schema check the relay wire gets.
+ *
+ * Continuity.Callback calls fromMap() DIRECTLY -- an NSUserActivity, or anything a custom
+ * bridge hands over, never touches fromJson -- so every check added for the relay was missing
+ * from the other way in. A continuation with a good origin and sequence but "routes" as a
+ * string dropped the field, produced an empty state, and admission consumed that as a
+ * tombstone and advanced the origin's durable high-water mark. Same harm, other path.
+ */
+ @EdtTest
+ public void aMalformedPlatformContinuationIsNotConsumedAsATombstone() {
+ Continuity.enable();
+ Continuity.setStateProvider(new RecordingProvider());
+ ContinuityCallback callback = Continuity.callbackForTest();
+
+ Map malformed = new HashMap();
+ malformed.put("device", "bridge-sender");
+ malformed.put("seq", "10");
+ // A LIST is what this field is; a string here is the malformed case, and it used to be
+ // dropped in silence.
+ malformed.put("routes", "/orders,/orders/17");
+
+ assertFalse(callback.continuationReceived(Continuity.getActivityType(), malformed),
+ "a malformed continuation was claimed, so the framework took responsibility for "
+ + "a document it could not read");
+ flushSerialCalls();
+
+ assertNull(Continuity.readSeenForTest().get("bridge-sender"),
+ "the malformed continuation was consumed as a tombstone and marked durably, so "
+ + "the sender's correction is refused after a restart as already seen");
+ }
+
+ /**
+ * A raw control character inside a JSON string is a failed read, not a tombstone.
+ *
+ * The grammar check accepted every unescaped character except quote and backslash, and
+ * JSON allows neither below U+0020. It is not a formality: the framework parser appends a
+ * raw control byte to whatever it is building rather than stopping, so a document carrying a
+ * literal newline inside a KEY -- "pay(LF)load" -- passed as valid and came out with a key
+ * that is not "payload". The field is unknown and dropped, and a state with no payload and
+ * no routes is a tombstone, which the sending device meant as nothing of the sort.
+ */
+ @EdtTest
+ public void aRawControlCharacterInAStringIsRefused() throws Exception {
+ String withNewlineInAKey =
+ "{\"device\":\"other\",\"seq\":\"10\",\"pay\nload\":{\"a\":1}}";
+ try {
+ AppState s = StateCodec.fromJson(withNewlineInAKey);
+ fail("a raw newline inside a JSON string was accepted"
+ + (s != null && s.isEmpty()
+ ? " -- and as an EMPTY state, which is read as a tombstone: the "
+ + "sending device is told to have cleared its work"
+ : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+
+ // The ESCAPED form is the legitimate one and must still go through, or this guard would
+ // refuse every payload that contains a line break.
+ AppState fine = StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"10\",\"enc\":\"1\","
+ + "\"payload\":{\"note\":\"s:two\\nlines\"}}");
+ assertEquals("two\nlines", fine.getPayload().get("note"),
+ "an escaped newline was mangled, so the guard refuses legitimate documents");
+ }
+
+ /**
+ * An arrival admitted before disable() is not dispatched by an enable() that follows.
+ *
+ * {@code enabled} alone could not see a disable() and an enable() that BOTH ran before the
+ * queued dispatch did -- two queued turns are enough, and the flag is true again by the time
+ * it is read -- so the arrival from before the disable was dispatched and restored after
+ * all. lastSeen still holds its sequence, so the supersession check waves it through too. The
+ * generation is the field that remembers a session ended, which is what disable() actually
+ * promises.
+ */
+ @EdtTest
+ public void anArrivalAdmittedBeforeDisableIsNotDispatchedByALaterEnable() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+
+ // Admitted, which queues the dispatch for the next turn.
+ Continuity.deliver(fromElsewhere("the previous session's work", 150L));
+
+ // Both land BEFORE that queued turn runs, which is what the flag could not see.
+ Display.getInstance().callSerially(new Runnable() {
+ public void run() {
+ Continuity.disable();
+ Continuity.enable();
+ }
+ });
+ flushSerialCalls();
+ flushSerialCalls();
+
+ assertNull(provider.restored,
+ "a state admitted before disable() was restored by the enable() that followed, "
+ + "though disable() documents that arriving states are ignored from the "
+ + "moment it is called, including the ones already admitted");
+ }
+
+ /**
+ * A key whose value is gone is not listed, even when the index still names it.
+ *
+ * The sibling of the deletion-failure case, in the other direction: the value IS deleted
+ * and the index write then fails, so the stored index goes on naming a key whose get()
+ * answers the default. The platform being simulated has no such gap --
+ * NSUbiquitousKeyValueStore enumerates its own dictionary, so a phantom key cannot exist
+ * there -- and the simulation should not invent one.
+ */
+ @EdtTest
+ public void aKeyWhoseValueIsGoneIsNotListed() {
+ Storage real = Storage.getInstance();
+ LocalContinuityBridge b = new LocalContinuityBridge();
+ try {
+ assertTrue(b.syncedStorePut("locale", "en"), "the fixture could not write a value");
+ assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("locale"),
+ "the fixture's own key is not listed, so this test is about nothing");
+
+ // The scenario itself rather than a hand-made facsimile of it: from here every write
+ // fails while deletes and reads still work, so the remove below deletes the value
+ // successfully and cannot rewrite the index.
+ Storage.setStorageInstance(new WriteRefusingStorage(real));
+ b.syncedStoreRemove("locale");
+ assertNull(b.syncedStoreGet("locale"),
+ "the fixture is wrong: the value survived, so there is no phantom entry to "
+ + "test");
+
+ assertFalse(java.util.Arrays.asList(b.syncedStoreKeys()).contains("locale"),
+ "keys() named a key whose value is not there, so an application walking the "
+ + "store reads the default for a key the store says it has");
+ } finally {
+ Storage.setStorageInstance(real);
+ new LocalContinuityBridge().syncedStoreRemove("locale");
+ }
+ }
+
+ /**
+ * A synced-store value that would not delete keeps its index entry.
+ *
+ * The two writes are the value and the index, and every caller has to know which of them
+ * happened. Dropping the index entry for a value the delete failed to remove left the old
+ * value readable through get() while keys() omitted it and clearing the store could not
+ * reach it -- a value with no way to see it and no way to remove it.
+ *
+ * put()'s rollback had the identical unchecked delete: when the index write fails it
+ * removes the value it just wrote, and claimed a cleanup it had not performed. That one came
+ * out of enumerating the file rather than from the report.
+ */
+ @EdtTest
+ public void aValueThatWillNotDeleteKeepsItsIndexEntry() {
+ Storage real = Storage.getInstance();
+ try {
+ LocalContinuityBridge b = new LocalContinuityBridge();
+ assertTrue(b.syncedStorePut("theme", "dark"), "the fixture could not write a value");
+ assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("theme"),
+ "the fixture's own key is not listed, so this test is about nothing");
+
+ // A store whose delete does nothing at all, which is what a file the desktop cannot
+ // remove looks like from in here.
+ Storage.setStorageInstance(new UndeletableStorage(real));
+
+ b.syncedStoreRemove("theme");
+
+ assertEquals("dark", b.syncedStoreGet("theme"),
+ "the fixture is wrong: the value did go away, so there is no divergence to "
+ + "test");
+ assertTrue(java.util.Arrays.asList(b.syncedStoreKeys()).contains("theme"),
+ "the index dropped a key whose value is still readable through get(), so "
+ + "the value cannot be listed, cannot be found by a store-wide "
+ + "cleanup, and cannot be removed");
+ } finally {
+ Storage.setStorageInstance(real);
+ new LocalContinuityBridge().syncedStoreRemove("theme");
+ }
+ }
+
+ /**
+ * A provider that signs out and then throws stops the restore before any route runs.
+ *
+ * The guard sat on the normal-return path only, so a provider that called clear() and then
+ * failed -- cleanup breaking after it noticed an expired account -- was carried past it by
+ * the catch, and the route rebuild ran for the session that had just ended. The later
+ * lifecycle check does undo the stack, but only after that account's route factories, form
+ * constructors and show callbacks have run and put its data in front of the user.
+ *
+ * The same mistake capture() had, in the method that mirrors it. Found there first, and
+ * still here.
+ */
+ @EdtTest
+ public void aProviderThatSignsOutAndThenThrowsStopsBeforeTheRoutes() {
+ final java.util.concurrent.atomic.AtomicInteger routesBuilt =
+ new java.util.concurrent.atomic.AtomicInteger();
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String url) {
+ routesBuilt.incrementAndGet();
+ Form f = new Form();
+ f.setTitle(url);
+ return f;
+ }
+ });
+ try {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ // The documented answer to "this payload belongs to a signed-out account",
+ // and then a failure on the way out of it.
+ Continuity.clear();
+ throw new IllegalStateException("the cleanup after the logout failed");
+ }
+ });
+ Continuity.setAutoRestore(false);
+
+ Map payload = new HashMap();
+ payload.put("account", "the previous one");
+ AppState arriving = new AppState()
+ .setPayload(payload)
+ .setRoutes(java.util.Arrays.asList("/orders", "/orders/17"))
+ .setDeviceId("some-other-device")
+ .setSequence(140L)
+ .setTimestamp(System.currentTimeMillis());
+ Continuity.restore(arriving);
+ flushSerialCalls();
+
+ assertEquals(0, routesBuilt.get(),
+ "the signed-out account's routes were dispatched anyway: its route factories, "
+ + "form constructors and show callbacks all ran, and undoing the "
+ + "stack afterwards cannot unrun them");
+ } finally {
+ // The stack too: nothing resets Navigation between tests, and a test that leaves
+ // entries behind breaks the NEXT test's fixture rather than its own assertions.
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A listener that signs out and then throws is not followed by the next listener.
+ *
+ * The check was after the {@code continue}, so a throw jumped straight past it and the
+ * next listener was handed the signed-out account's state. The check at the end of dispatch
+ * stops the restore, but it cannot undo what that listener did with the payload, or unsee
+ * it.
+ */
+ @EdtTest
+ public void aListenerThatSignsOutAndThenThrowsStopsTheWalk() {
+ final java.util.concurrent.atomic.AtomicInteger secondSaw =
+ new java.util.concurrent.atomic.AtomicInteger();
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(false);
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ Continuity.clear();
+ throw new IllegalStateException("the cleanup after the logout failed");
+ }
+ });
+ Continuity.addContinuationListener(new ContinuityListener() {
+ public boolean stateReceived(AppState state) {
+ secondSaw.incrementAndGet();
+ return true;
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("the previous account's work", 141L));
+ flushSerialCalls();
+
+ assertEquals(0, secondSaw.get(),
+ "the second listener was handed a state from a session the first had already "
+ + "ended, and nothing afterwards can unsee it");
+ }
+
+ /**
+ * A continuation arriving after an explicit disable() is DROPPED, not held for the next
+ * enable().
+ *
+ * The sibling above is the other half, and the two want opposite answers from the same
+ * {@code enabled == false}. Declining is right before the application's first enable(),
+ * because the port holds a declined activity and offers it again when a callback is next
+ * installed -- which is exactly what recovers a cold-launch Handoff. After an explicit
+ * disable() that same retention delivered a state from the interval disable() documents as
+ * ignored, whenever the application switched continuity back on.
+ *
+ * Claiming is what discards it: the port lets go of an activity that was handled. Nothing
+ * else answers to this application's own activity type, so taking it costs no other handler
+ * anything.
+ */
+ @EdtTest
+ public void aContinuationArrivingAfterDisableIsDroppedRatherThanHeld() {
+ Continuity.enable();
+ Continuity.disable();
+ ContinuityCallback callback = Continuity.callbackForTest();
+
+ Map info = StateCodec.toMap(fromElsewhere("during the off period", 91L));
+ assertTrue(callback.continuationReceived(Continuity.getActivityType(), info),
+ "the callback declined a continuation after an explicit disable(), so the port "
+ + "holds it and the next enable() restores a state that arrived while "
+ + "the application had said it wanted none");
+
+ // Dropped, not delivered: claiming must not become a back door into the disabled
+ // framework either.
+ Continuity.enable();
+ assertNull(Continuity.getRestorableState(),
+ "the arrival from the disabled interval was delivered after all");
+ }
+
+ /**
+ * The cold-launch waiter finishes, hands over, and leaves nothing set behind it.
+ *
+ * The waiter asks the EVENT THREAD whether a window has appeared rather than reading
+ * Display.getCurrent() itself: that method is not a field read -- for a disposed dialog or a
+ * menu it walks animationQueue by index, size first and then each element -- and a cold
+ * launch is exactly when the event thread is building forms and running transitions through
+ * that queue. This test pins the behaviour the marshalling has to keep: the parked arrival is
+ * still handed over once a window exists, and {@code waitingForWindow} is left clear so a
+ * LATER arrival can start a waiter of its own. That second half is what a throw inside the
+ * old worker destroyed -- the flag stayed set for the rest of the process and every
+ * subsequent arrival parked behind it with nothing coming to look.
+ */
+ @EdtTest
+ public void theColdLaunchWaiterHandsOverAndLeavesNothingSet() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ Continuity.parkForTest(fromElsewhere("first cold-launch arrival", 120L));
+ Continuity.drainParkedForTest();
+ AppState first = Continuity.getRestorableState();
+ assertNotNull(first, "the first parked arrival was never handed over");
+ assertEquals(120L, first.getSequence(), "a different state was handed over");
+ Continuity.restore(first);
+
+ // The SECOND is the point. A waiter that did not report back leaves waitingForWindow set,
+ // and this one is then parked for ever behind it.
+ Continuity.parkForTest(fromElsewhere("second arrival, later in the run", 121L));
+ Continuity.drainParkedForTest();
+ AppState second = Continuity.getRestorableState();
+ assertNotNull(second,
+ "a second arrival was never handed over, which is what a waiter that failed to "
+ + "report back leaves behind: waitingForWindow set for the rest of the "
+ + "process and every later arrival parked behind it");
+ assertEquals(121L, second.getSequence(), "the second arrival was not the one offered");
+ }
+
+ /**
+ * The eviction order of the delivery marks survives a restart.
+ *
+ * rememberSeen() writes durableSeen in its own order, least-recently-seen first, so the
+ * file carries the eviction order. Reading it back into a HashMap threw that away, and
+ * enable() replayed an arbitrary order into a map whose whole job is to evict the front -- so
+ * the next new origin could evict a device the user is actively using instead of the one
+ * quiet longest, and a delayed duplicate from the evicted device ran its side effects
+ * again.
+ */
+ @EdtTest
+ public void theEvictionOrderOfTheMarksSurvivesARestart() {
+ Continuity.setStateProvider(new RecordingProvider());
+ // A full set, acknowledged oldest first, so "device-0" is the one quiet longest.
+ for (int i = 0; i < 64; i++) {
+ Map payload = new HashMap();
+ payload.put("note", "seen " + i);
+ Continuity.acknowledge(new AppState()
+ .setPayload(payload)
+ .setDeviceId("device-" + i)
+ .setSequence(i + 1)
+ .setTimestamp(System.currentTimeMillis()));
+ }
+ flushSerialCalls();
+ assertEquals(64, Continuity.readSeenForTest().size(), "the fixture is not a full set");
+
+ // The restart: memory forgotten, the file reloaded.
+ Continuity.reset();
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.enable();
+
+ // One more origin, which must evict the eldest and only the eldest.
+ Map payload = new HashMap();
+ payload.put("note", "the new one");
+ Continuity.acknowledge(new AppState()
+ .setPayload(payload)
+ .setDeviceId("device-new")
+ .setSequence(99L)
+ .setTimestamp(System.currentTimeMillis()));
+ flushSerialCalls();
+
+ Map after = Continuity.readSeenForTest();
+ assertFalse(after.containsKey("device-0"),
+ "the eldest mark survived, so something else was evicted in its place");
+ assertTrue(after.containsKey("device-63"),
+ "the most recently seen device was evicted instead of the eldest, so a delayed "
+ + "duplicate from it reaches the listeners and repeats its side effects");
+ assertTrue(after.containsKey("device-new"), "the new origin was not recorded at all");
+ }
+
+ /**
+ * Logout leaves nothing owed, so a later flush cannot rebuild what it deleted.
+ *
+ * clear() empties the navigation stack, and that emptying set the pending flag before the
+ * guard that was meant to suppress it. A flush queued earlier -- or Android's next suspend --
+ * then performed the checkpoint, rebuilding the deleted state from the still-installed
+ * provider and publishing the signed-out account's payload after logout had removed it.
+ */
+ @EdtTest
+ public void logoutLeavesNothingOwedForALaterFlushToRebuild() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("secret", "the previous account's work");
+ Continuity.setStateProvider(provider);
+ Continuity.checkpoint();
+
+ Continuity.clear();
+
+ assertFalse(Continuity.isCheckpointPending(),
+ "logout left a checkpoint owed, so the next flush writes the signed-out "
+ + "account's payload back over the state it just deleted");
+ // And nothing a later flush could do brings it back.
+ flushSerialCalls();
+ assertNull(Continuity.getRestorableState(),
+ "a flush after logout restored the deleted checkpoint");
+ }
+
+ /**
+ * A provider that ends the session while restoring stops the restoration.
+ *
+ * Detecting that a payload belongs to a signed-out account is exactly what this callback is
+ * for, and clear() is the documented response. Restoration carried on regardless: it rebuilt
+ * the routes and committed, persisting the state clear() had just deleted. The listener
+ * callback had this guard; the provider is the other application callback on the path.
+ */
+ @EdtTest
+ public void aProviderThatLogsOutWhileRestoringStopsTheRestore() {
+ Continuity.setAutoRestore(true);
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ // "This payload is not the account that is signed in."
+ Continuity.clear();
+ }
+ });
+
+ Continuity.deliver(fromElsewhere("the previous account's work", 77L));
+ flushSerialCalls();
+
+ assertNull(Continuity.getRestorableState(),
+ "the arrival the provider logged out over is still on offer -- either persisted "
+ + "past the clear() or parked back into the session it just emptied, and "
+ + "both hand the signed-out account's work to whoever signs in next");
+ }
+
+ /**
+ * A route that logs out while being rebuilt stops the restore.
+ *
+ * Rebuilding a stack runs application code -- the route factory, the form's constructor,
+ * its show callback -- and any of it may discover the session is over. Committing afterwards
+ * repopulates both the navigation stack and the stored checkpoint with the signed-out
+ * account's state.
+ *
+ * This is the indirect case. The framework never calls the route factory; Navigation does,
+ * on its behalf, which is why an enumeration of the callbacks the framework invokes directly
+ * did not find it.
+ */
+ @EdtTest
+ public void aRouteThatLogsOutWhileBeingRebuiltStopsTheRestore() {
+ Continuity.setStateProvider(new RecordingProvider());
+ Continuity.setAutoRestore(true);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String path) {
+ // "The session behind this route has expired."
+ Continuity.clear();
+ return new Form(path);
+ }
+ });
+ try {
+ AppState arrival = fromElsewhere("the previous account's screen", 66L);
+ List routes = new ArrayList();
+ routes.add("/account/statement");
+ arrival.setRoutes(routes);
+
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+
+ assertNull(Continuity.getRestorableState(),
+ "the restore committed after the route logged out, so the signed-out "
+ + "account's stack and checkpoint are back");
+ assertTrue(Navigation.getStack().isEmpty(),
+ "the rebuilt stack survived the logout that happened while building it");
+ // The SCREEN as well as the history. clearStack() deliberately leaves the current
+ // form alone, so undoing only the stack left the signed-out account's work in front
+ // of the user with nothing but its breadcrumbs removed.
+ Form current = Display.getInstance().getCurrent();
+ assertNotNull(current, "no form is showing at all");
+ assertFalse("/account/statement".equals(current.getTitle()),
+ "the signed-out account's restored screen is still displayed after the "
+ + "logout that cancelled the restore");
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A logout stops a fetch that has not reached the network yet.
+ *
+ * The publish worker has confirmed its session on the event thread since the first round of
+ * this review; the poll worker never did, so only its COMPLETION was rejected -- after the
+ * read had gone out. A custom relay that resolves authentication inside fetch() would issue a
+ * request after logout, and could present the next account's credentials to the previous
+ * endpoint, while clear() promises that only a request already on the wire survives it.
+ */
+ @EdtTest
+ public void aLogoutStopsAFetchThatHasNotReachedTheNetwork() {
+ Continuity.setStateProvider(new RecordingProvider());
+ final java.util.concurrent.atomic.AtomicInteger reads =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.CountDownLatch letGo =
+ new java.util.concurrent.CountDownLatch(1);
+
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ }
+
+ public AppState fetch() {
+ // The worker reaches here only if the session check let it through. Waiting first
+ // so the test can log out while it is still queued.
+ try {
+ letGo.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ reads.incrementAndGet();
+ return null;
+ }
+ });
+
+ // The worker is created and queued; the logout lands before it runs.
+ Continuity.clear();
+ letGo.countDown();
+ pause(500L);
+ flushSerialCalls();
+
+ assertEquals(0, reads.get(),
+ "a relay read went out after logout, so a relay that resolves its credentials "
+ + "inside fetch() presents the next account's to the previous endpoint");
+ }
+
+ /**
+ * A truncated relay document is a failed read, not an empty relay.
+ *
+ * JSONParser does not throw on malformed input: it logs and returns the partial map it had
+ * built. A document cut off after "device" and "seq" therefore parsed into a state with no
+ * routes and no payload -- which this framework reads as a TOMBSTONE -- so the origin was
+ * recorded as having cleared its work, durably, while fetch() reported a successful read and
+ * released a queued POST over the relay's real document.
+ */
+ @EdtTest
+ public void aTruncatedRelayDocumentIsAFailedReadNotAnEmptyRelay() {
+ String whole = StateCodec.toJson(fromElsewhere("what the other device was doing", 42L));
+ assertTrue(whole.length() > 20, "the fixture document is too short to truncate usefully");
+ String cut = whole.substring(0, whole.length() / 2);
+
+ // The control: the whole document still reads.
+ try {
+ assertNotNull(StateCodec.fromJson(whole), "a complete document failed to parse");
+ } catch (java.io.IOException e) {
+ fail("a complete document was refused: " + e.getMessage());
+ }
+
+ try {
+ AppState partial = StateCodec.fromJson(cut);
+ fail("a truncated document was accepted"
+ + (partial != null && partial.isEmpty()
+ ? " as an EMPTY state, which is read as a tombstone: the origin is "
+ + "recorded as having cleared its work and the relay's real "
+ + "document is then overwritten"
+ : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().contains("valid JSON object"),
+ expected.getMessage());
+ }
+ }
+
+ /**
+ * A simulated-store write that could not be indexed leaves nothing behind.
+ *
+ * Returning false while the value stayed durable made the answer a lie in the other
+ * direction: the caller takes its documented fallback path while get() returns the value it
+ * was told had failed, keys() omits it, and clearing the store cannot reach it.
+ */
+ @EdtTest
+ public void aStoreWriteThatCouldNotBeIndexedLeavesNothingBehind() {
+ Storage original = Storage.getInstance();
+ Storage.setStorageInstance(new RefusingOneStorage(original, "CN1$SyncedStoreKeys"));
+ boolean reported;
+ try {
+ reported = SyncedStore.put("draft", "half a sentence");
+ } finally {
+ Storage.setStorageInstance(original);
+ }
+
+ assertFalse(reported, "the write was reported as successful although the index refused");
+ assertEquals("missing", SyncedStore.get("draft", "missing"),
+ "the value the caller was told had FAILED is readable, so the application took "
+ + "its fallback path over a value that is really there -- and keys() and "
+ + "clearing cannot see it");
+ }
+
+ /**
+ * A syntactically invalid document is refused even when its delimiters balance.
+ *
+ * The first version of this guard counted braces and closed strings, which catches a
+ * document cut in half and lets a bad TOKEN through -- and the parser answers a bad token the
+ * same way it answers truncation: it logs, and returns the map it had built so far. A partial
+ * state that looks like a tombstone has the same consequences either way.
+ */
+ @EdtTest
+ public void aSyntacticallyInvalidDocumentIsRefusedEvenWhenBalanced() throws Exception {
+ // Balanced braces, closed strings, invalid: "tru" is not a token.
+ String balancedButInvalid = "{\"device\":\"d\",\"seq\":\"2\",\"payload\":tru}";
+ assertFalse(StateCodec.isValidJsonObject(balancedButInvalid),
+ "a bad token passed the check, so the parser's partial map becomes a state");
+
+ // The shapes that must still be accepted, or the check is just breaking the feature.
+ assertTrue(StateCodec.isValidJsonObject(
+ StateCodec.toJson(fromElsewhere("a real one", 3L))),
+ "a document this codec itself wrote was refused");
+ assertTrue(StateCodec.isValidJsonObject("{}"), "an empty object was refused");
+ assertTrue(StateCodec.isValidJsonObject(
+ "{\"a\":[1,-2.5e3,true,null,{\"b\":\"\\u00e9\"}]}"),
+ "a valid nested document was refused");
+
+ // And the shapes that must not be.
+ assertFalse(StateCodec.isValidJsonObject("{\"a\":1,}"), "a trailing comma was accepted");
+ assertFalse(StateCodec.isValidJsonObject("{\"a\":1} junk"),
+ "trailing content after the object was accepted");
+ assertFalse(StateCodec.isValidJsonObject("{\"a\":\"unterminated}"),
+ "an unterminated string was accepted");
+ assertFalse(StateCodec.isValidJsonObject("{\"a\":01}"),
+ "a malformed number was accepted");
+ assertFalse(StateCodec.isValidJsonObject("{\"a\":\"\\uZZZZ\"}"),
+ "a bad unicode escape was accepted");
+ }
+
+ /**
+ * A tombstone does not release the publisher while a coalesced read is still owed.
+ *
+ * pollFinished() clears {@code polling} before the tombstone is handled, so releasing here
+ * started the POST BEFORE the follow-up GET and then ran the two together -- against a relay
+ * that holds one document, which is exactly what the one-fetch-at-a-time rule exists to
+ * prevent. The remote update the second read was going to see is overwritten, and that read
+ * comes back with this device's own echo.
+ */
+ @EdtTest
+ public void aTombstoneDoesNotReleaseThePublisherWhileAReadIsOwed() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+
+ // The tombstone arrives FROM the read, which is what makes this reachable: pollFinished()
+ // clears `polling` before handing the state to admit(), so the tombstone branch runs in a
+ // window where the publisher looks free while a coalesced read is still owed. A first
+ // version delivered the tombstone by hand while a fetch was blocked -- `polling` was
+ // still true, startPublisher() stopped at its own guard, and the test passed against the
+ // unfixed code.
+ final AppState tombstone = new AppState()
+ .setDeviceId("some-other-device")
+ .setSequence(41L)
+ .setTimestamp(System.currentTimeMillis());
+ final java.util.concurrent.atomic.AtomicInteger reads =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.CountDownLatch inTombstoneRead =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch releaseTombstone =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.CountDownLatch releaseSecond =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.atomic.AtomicInteger publishedWhileReading =
+ new java.util.concurrent.atomic.AtomicInteger();
+ final java.util.concurrent.atomic.AtomicBoolean reading =
+ new java.util.concurrent.atomic.AtomicBoolean();
+
+ Continuity.setRelay(new StateRelay() {
+ public void publish(AppState state) {
+ if (reading.get()) {
+ publishedWhileReading.incrementAndGet();
+ }
+ published.add(state);
+ }
+
+ public AppState fetch() {
+ int n = reads.incrementAndGet();
+ if (n == 1) {
+ // setRelay() polls immediately. Answering nothing here leaves the arrival
+ // below free to park -- returning the tombstone on this read admitted it
+ // FIRST, so the seq-40 arrival was refused as already seen and there was
+ // nothing to supersede.
+ return null;
+ }
+ if (n == 2) {
+ // Held so the test can ask for another read WHILE this one is in flight,
+ // which is what sets pollAgain -- and then answers with the tombstone, so it
+ // reaches admit() from pollFinished() with `polling` already cleared.
+ inTombstoneRead.countDown();
+ try {
+ releaseTombstone.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ return tombstone;
+ }
+ // The coalesced follow-up, held open so an overlapping POST is observable.
+ reading.set(true);
+ try {
+ releaseSecond.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ reading.set(false);
+ return null;
+ }
+ });
+
+ // An arrival parked, so the tombstone has something to supersede, and work owed to the
+ // relay so there is a POST to release.
+ Continuity.deliver(fromElsewhere("waiting on the user", 40L));
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(), "nothing parked to supersede");
+ // Owed to the relay, and HELD by the parked arrival rather than sent.
+ Continuity.checkpoint();
+
+ // The read that will answer with the tombstone.
+ Continuity.pollRelay();
+ final boolean[] inRead = new boolean[1];
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ try {
+ inRead[0] = inTombstoneRead.await(5L, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ });
+ assertTrue(inRead[0], "the tombstone read never started");
+ // Asked for WHILE that read is in flight, which is what sets pollAgain.
+ Continuity.pollRelay();
+ releaseTombstone.countDown();
+
+ pause(600L);
+ flushSerialCalls();
+ pause(400L);
+ flushSerialCalls();
+
+ assertTrue(reads.get() >= 2,
+ "the coalesced follow-up read never happened, so nothing could overlap: reads="
+ + reads.get());
+ assertEquals(0, publishedWhileReading.get(),
+ "the tombstone released a POST while a coalesced read was still outstanding, so "
+ + "the two ran together against a relay that holds one document");
+
+ releaseSecond.countDown();
+ pause(400L);
+ flushSerialCalls();
+ }
+
+ /**
+ * A restored stack that cannot be shown leaves the previous one in place.
+ *
+ * show() runs application code. If it throws, the stack had already been replaced, so the
+ * old form stayed on screen while getCurrent(), back() and the next checkpoint all described
+ * a stack the user never saw -- and a later navigation persisted a restoration that failed.
+ */
+ @EdtTest
+ public void aRestoredStackThatCannotBeShownLeavesThePreviousOne() {
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String path) {
+ if (path.startsWith("/explodes")) {
+ return new Form(path) {
+ @Override
+ public void show() {
+ throw new IllegalStateException("this screen cannot be shown");
+ }
+ };
+ }
+ return new Form(path);
+ }
+ });
+ try {
+ Navigation.navigate("/account/statement");
+ flushSerialCalls();
+ assertEquals(1, Navigation.getStack().size(), "the fixture stack was not established");
+
+ List restored = new ArrayList();
+ restored.add("/explodes");
+ try {
+ Navigation.restoreStack(restored);
+ fail("showing threw, so restoreStack must not report success");
+ } catch (IllegalStateException expected) {
+ assertEquals("this screen cannot be shown", expected.getMessage());
+ }
+
+ assertEquals(1, Navigation.getStack().size(),
+ "the stack was replaced by a restoration that could not be shown, so back() "
+ + "and the next checkpoint describe screens the user never saw");
+ assertEquals("/account/statement", Navigation.getStack().get(0).getPath(),
+ "the previous stack was not the one that survived");
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A navigation whose own show() ended the session does not checkpoint afterwards.
+ *
+ * Navigation notifies continuity AFTER the route's form has been shown, and a show callback
+ * calling clear() on an expired login is the ordinary way to end a session. The notification
+ * then described a session that no longer existed, and checkpointing it captured whatever the
+ * provider still held for the account that had just signed out -- while clear() promises that
+ * nothing follows it.
+ */
+ @EdtTest
+ public void aNavigationWhoseShowEndedTheSessionDoesNotCheckpoint() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("secret", "the previous account's work");
+ Continuity.setStateProvider(provider);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String path) {
+ // Only the account screen discovers the expiry. An unconditional logout made the
+ // login navigation below end the session too, so the control could never pass --
+ // and a control that cannot pass is not a control.
+ if (!"/account/statement".equals(path)) {
+ return new Form(path);
+ }
+ return new Form(path) {
+ @Override
+ public void show() {
+ super.show();
+ // "This login has expired."
+ Continuity.clear();
+ }
+ };
+ }
+ });
+ try {
+ Navigation.navigate("/account/statement");
+ flushSerialCalls();
+
+ assertFalse(Continuity.isCheckpointPending(),
+ "the navigation that ended the session left a checkpoint owed, so the "
+ + "signed-out account's payload is written back after logout");
+ assertNull(Continuity.getRestorableState(),
+ "a checkpoint was written after the logout that cancelled it");
+
+ // And the NEXT navigation is ordinary: one notification is skipped, not a mode.
+ //
+ // Asserted on what was WRITTEN, not on the pending flag: the flush performs the
+ // checkpoint and checkpoint() clears that flag, so it reads false either way. That
+ // observable has now been the wrong one three times on this branch.
+ Navigation.navigate("/login");
+ flushSerialCalls();
+ assertNotNull(Continuity.getRestorableState(),
+ "navigation stopped checkpointing altogether after a logout, so nothing is "
+ + "ever stored again for the account that signs in next");
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A remote route this device cannot store never enters the navigation stack.
+ *
+ * Decoding accepts a remote document's routes unchecked, deliberately -- another device's
+ * mistake must not throw here. But an accepted route reaches the live stack, and the next
+ * checkpoint reads that stack back through the validating setter: one route past this
+ * device's limit threw out of capture(), left the pending flag set, and every later
+ * navigation retried the same throw while nothing was persisted or published again.
+ */
+ @EdtTest
+ public void aRemoteRouteThisDeviceCannotStoreNeverEntersTheStack() {
+ RecordingProvider provider = new RecordingProvider();
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(true);
+ Navigation.setDispatcher(new RouteDispatcher() {
+ public Form dispatch(String path) {
+ return new Form(path);
+ }
+ });
+ try {
+ StringBuilder huge = new StringBuilder("/");
+ while (huge.length() <= 65535) {
+ huge.append('r');
+ }
+ AppState arrival = fromElsewhere("from a device with a longer limit", 55L);
+ List routes = new ArrayList();
+ routes.add(huge.toString());
+ routes.add("/account/statement");
+ // Unchecked, exactly as a relay document would arrive.
+ arrival.setRoutesUnchecked(routes);
+
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+
+ // The usable route still restored; the impossible one did not come with it.
+ for (int i = 0; i < Navigation.getStack().size(); i++) {
+ assertTrue(Navigation.getStack().get(i).getPath().length() < 65535,
+ "a route this device cannot store entered the navigation stack, so the "
+ + "next checkpoint throws out of capture() for ever");
+ }
+
+ // And a checkpoint still works, which is what the stack poisoning prevented.
+ Continuity.checkpoint();
+ flushSerialCalls();
+ assertFalse(Continuity.isCheckpointPending(),
+ "capture() could not complete, so nothing is persisted or published again");
+ } finally {
+ Navigation.setDispatcher(null);
+ Navigation.clearStack();
+ }
+ }
+
+ /**
+ * A relay field of the wrong type is a failed read, not an empty state.
+ *
+ * Valid syntax is not a valid state. {@code {"device":"other","seq":"10","payload":[]}}
+ * parses cleanly and fromMap ignores the array where a payload belongs, leaving routes and
+ * payload both empty -- which is an EMPTY state, which the framework reads as a tombstone. One
+ * wrong type therefore records the origin as having cleared its work, marks it durably, and
+ * releases a queued publish over the server's document.
+ */
+ @EdtTest
+ public void aRelayFieldOfTheWrongTypeIsAFailedRead() throws Exception {
+ // The control first: a document this codec writes must still read.
+ assertNotNull(StateCodec.fromJson(StateCodec.toJson(fromElsewhere("real", 4L))),
+ "a document this codec itself wrote was refused");
+
+ String[] wrong = {
+ "{\"device\":\"other\",\"seq\":\"10\",\"payload\":[]}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"routes\":{}}",
+ "{\"device\":42,\"seq\":\"10\"}",
+ "{\"device\":\"other\",\"seq\":\"not a number\"}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"title\":[1,2]}",
+ // The array is an array; its CONTENTS are the door. Checking the container alone let
+ // these through, and the reader that drops what it cannot use turned each of them
+ // into a state with fewer routes than the sender meant.
+ "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[1]}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",null]}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"routes\":[\"/a\",{\"b\":1}]}",
+ // A sequence that is a NUMBER but not one this device can hold. asLong() clamps
+ // 1e100 to Long.MAX_VALUE, and once that is the durable high-water mark for this
+ // origin every ordinary sequence it sends afterwards is refused as already seen --
+ // for the life of the installation.
+ "{\"device\":\"other\",\"seq\":1e100}",
+ "{\"device\":\"other\",\"seq\":-1e100}",
+ "{\"device\":\"other\",\"seq\":10,\"ts\":1e300}",
+ // Fractional, which is the same harm in miniature: 5.7 becomes 5, so the sender's
+ // own 5 is then indistinguishable from it.
+ "{\"device\":\"other\",\"seq\":5.7}",
+ // 2^63, one past the range. It is the SAME double as (double) Long.MAX_VALUE --
+ // that constant is not representable and rounds up to this -- so a "greater than
+ // Long.MAX_VALUE" test compares equal and lets it through, to be clamped straight
+ // back to Long.MAX_VALUE by the conversion.
+ "{\"device\":\"other\",\"seq\":9223372036854775808}",
+ // A raw control character INSIDE a string. JSON forbids it unescaped, and the
+ // framework parser appends it rather than stopping -- so the key here is not
+ // "payload", the field is dropped as unknown, and what is left is a tombstone.
+ "{\"device\":\"other\",\"seq\":\"10\",\"pay\nload\":{\"a\":1}}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"title\":\"two\u0000parts\"}",
+ };
+ for (int i = 0; i < wrong.length; i++) {
+ try {
+ AppState s = StateCodec.fromJson(wrong[i]);
+ fail("a document with a wrong field type was accepted: " + wrong[i]
+ + (s != null && s.isEmpty()
+ ? " -- and as an EMPTY state, which is read as a tombstone"
+ : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+ }
+
+ // A field this codec does not know is NOT a failure: that is how the format stays
+ // extensible, and refusing it would break every sender that knows more than this build.
+ assertNotNull(StateCodec.fromJson(
+ "{\"device\":\"other\",\"seq\":\"10\",\"somethingNewer\":{\"a\":1}}"),
+ "an unknown field was refused, so a newer sender cannot talk to this build");
+ }
+
+ /**
+ * A provider that ends the session and then throws stops the capture too.
+ *
+ * The lifecycle check sat on the normal-return path only, so a provider that called
+ * clear() and then failed -- cleanup breaking after it noticed an expired account -- carried
+ * on and had its state persisted and advertised for the account that had just signed out.
+ */
+ @EdtTest
+ public void aProviderThatLogsOutAndThenThrowsStopsTheCapture() {
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ Continuity.clear();
+ throw new IllegalStateException("cleanup failed after the logout");
+ }
+
+ public void restoreState(Map payload) {
+ }
+ });
+
+ Continuity.checkpoint();
+ flushSerialCalls();
+
+ assertNull(Continuity.getRestorableState(),
+ "a checkpoint was written for the account the provider had just signed out of");
+ }
+
+ /**
+ * A relay field that is present and null is a failed read.
+ *
+ * The convenience parser drops null-valued fields before anything can look at them, so
+ * {@code {"payload":null}} arrived as an ABSENT payload -- and absent routes with an absent
+ * payload is an empty state, which the framework reads as a tombstone. The type checks added
+ * for {@code payload:[]} could not see it.
+ */
+ @EdtTest
+ public void aRelayFieldThatIsPresentAndNullIsAFailedRead() throws Exception {
+ String[] nulls = {
+ "{\"device\":\"other\",\"seq\":\"10\",\"payload\":null}",
+ "{\"device\":\"other\",\"seq\":\"10\",\"routes\":null}",
+ "{\"device\":\"other\",\"seq\":null}",
+ };
+ for (int i = 0; i < nulls.length; i++) {
+ try {
+ AppState got = StateCodec.fromJson(nulls[i]);
+ fail("a null field was accepted: " + nulls[i]
+ + (got != null && got.isEmpty() ? " -- as an empty state, a tombstone" : ""));
+ } catch (java.io.IOException expected) {
+ assertTrue(expected.getMessage().length() > 0, "the refusal explained nothing");
+ }
+ }
+
+ // Leaving the key OUT is how a sender says absent, and must still work.
+ assertNotNull(StateCodec.fromJson("{\"device\":\"other\",\"seq\":\"10\"}"),
+ "a document that simply omits a field was refused");
+ }
+
+ /** Storage that refuses ONE name and passes everything else through. */
+ /** Storage whose writes all fail while reads and deletes still work -- a full disk, from
+ * the point of view of code that has to keep two writes in step. */
+ static class WriteRefusingStorage extends Storage {
+ private final Storage delegate;
+
+ WriteRefusingStorage(Storage delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ return false;
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(name);
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(name);
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ delegate.deleteStorageFile(name);
+ }
+ }
+
+ /** Storage that folds every name to lower case before delegating, the way the default
+ * filesystems on macOS and Windows resolve paths. */
+ static class CaseFoldingStorage extends Storage {
+ private final Storage delegate;
+
+ CaseFoldingStorage(Storage delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ return delegate.writeObject(fold(name), o);
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(fold(name));
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(fold(name));
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ delegate.deleteStorageFile(fold(name));
+ }
+
+ private static String fold(String name) {
+ return name == null ? null : name.toLowerCase();
+ }
+ }
+
+ /** Storage that trims trailing dots and spaces from every name, the way Windows normalises
+ * a filename. */
+ static class SuffixTrimmingStorage extends Storage {
+ private final Storage delegate;
+
+ SuffixTrimmingStorage(Storage delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ return delegate.writeObject(trim(name), o);
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(trim(name));
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(trim(name));
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ delegate.deleteStorageFile(trim(name));
+ }
+
+ private static String trim(String name) {
+ if (name == null) {
+ return null;
+ }
+ int end = name.length();
+ while (end > 0 && (name.charAt(end - 1) == '.' || name.charAt(end - 1) == ' ')) {
+ end--;
+ }
+ return name.substring(0, end);
+ }
+ }
+
+ /** Storage that applies every name-mangling rule a host filesystem has been seen to apply:
+ * case folding, trailing dot and space trimming, and Unicode normalisation. */
+ static class HostileNamingStorage extends Storage {
+ private final Storage delegate;
+
+ HostileNamingStorage(Storage delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ return delegate.writeObject(mangle(name), o);
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(mangle(name));
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(mangle(name));
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ delegate.deleteStorageFile(mangle(name));
+ }
+
+ private static String mangle(String name) {
+ if (name == null) {
+ return null;
+ }
+ String out = name.toLowerCase();
+ int end = out.length();
+ while (end > 0 && (out.charAt(end - 1) == '.' || out.charAt(end - 1) == ' ')) {
+ end--;
+ }
+ out = out.substring(0, end);
+ // Composed and decomposed accents become one name, the way a normalising filesystem
+ // resolves them.
+ StringBuilder folded = new StringBuilder();
+ for (int i = 0; i < out.length(); i++) {
+ char c = out.charAt(i);
+ if (c == '\u00e9') {
+ folded.append("e\u0301");
+ } else {
+ folded.append(c);
+ }
+ }
+ return folded.toString();
+ }
+ }
+
+ /** A bridge that counts how many callbacks it was given. */
+ static class CountingBridge extends LocalContinuityBridge {
+ int callbacks;
+
+ @Override
+ public void setCallback(ContinuityCallback c) {
+ super.setCallback(c);
+ if (c != null) {
+ callbacks++;
+ }
+ }
+ }
+
+ static class RefusingOneStorage extends Storage {
+ private final Storage delegate;
+ private final String refused;
+
+ RefusingOneStorage(Storage delegate, String refused) {
+ this.delegate = delegate;
+ this.refused = refused;
+ }
+
+ @Override
+ public boolean writeObject(String name, Object o) {
+ if (refused.equals(name)) {
+ return false;
+ }
+ return delegate.writeObject(name, o);
+ }
+
+ @Override
+ public Object readObject(String name) {
+ return delegate.readObject(name);
+ }
+
+ @Override
+ public boolean exists(String name) {
+ return delegate.exists(name);
+ }
+
+ @Override
+ public void deleteStorageFile(String name) {
+ delegate.deleteStorageFile(name);
+ }
+ }
+
+ /**
+ * Declining an arrival with acknowledge() must release it. The state stays in the parked slot
+ * otherwise, so getRestorableState() goes on offering something the application has already
+ * dealt with -- and the checkpoint hold, which exists to protect a live arrival's only copy,
+ * goes on withholding publications for a state nobody will ever restore.
+ */
+ @EdtTest
+ public void acknowledgingAParkedArrivalReleasesItAndTheHeldPublication() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ final GatedRelay r = new GatedRelay();
+ Continuity.setRelay(r);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitEntered();
+ }
+ });
+ r.release();
+ pause(300L);
+ final int before = r.sent.size();
+
+ AppState arrival = fromElsewhere("the user says no", 101L);
+ Continuity.deliver(arrival);
+ flushSerialCalls();
+ Continuity.checkpoint();
+ pause(250L);
+ assertEquals(before, r.sent.size(), "the checkpoint should be held while it is parked");
+
+ Continuity.acknowledge(arrival);
+
+ // Identity, not null. getRestorableState() falls back to the LOCAL checkpoint when
+ // nothing is parked, and this test just wrote one -- so asserting null here fails on
+ // correct behaviour. What must not come back is the arrival that was acknowledged.
+ AppState left = Continuity.getRestorableState();
+ assertFalse(left != null && "some-other-device".equals(left.getDeviceId()),
+ "an acknowledged arrival is still being offered for restoration");
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitAnySince(before);
+ }
+ });
+ assertTrue(r.sent.size() > before,
+ "the checkpoint stayed held behind a state the application had acknowledged");
+ }
+
+ /**
+ * And an arrival that expires while parked releases the hold too. The hold protects the
+ * relay's only copy of a LIVE arrival; an expired one will never be restored by anything, so
+ * holding a checkpoint behind it just means it never reaches the user's other devices.
+ */
+ @EdtTest
+ public void anExpiredParkedArrivalReleasesTheHeldPublication() {
+ RecordingProvider provider = new RecordingProvider();
+ provider.saved.put("n", Integer.valueOf(1));
+ Continuity.setStateProvider(provider);
+ Continuity.setAutoRestore(false);
+ final GatedRelay r = new GatedRelay();
+ Continuity.setRelay(r);
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitEntered();
+ }
+ });
+ r.release();
+ pause(300L);
+ final int before = r.sent.size();
+
+ Continuity.parkForTest(fromElsewhere("goes stale while waiting", 103L));
+ Continuity.setMaxAge(1L);
+ Continuity.checkpoint();
+ pause(250L);
+
+ // Asking is what discards the expired arrival, and the hold has to go with it. Checked
+ // by identity for the reason the sibling above gives: a local checkpoint is a legitimate
+ // answer here, the expired arrival is not.
+ AppState left = Continuity.getRestorableState();
+ assertFalse(left != null && "some-other-device".equals(left.getDeviceId()),
+ "an expired parked state was still offered");
+ awaitOffEdt(new Runnable() {
+ public void run() {
+ r.awaitAnySince(before);
+ }
+ });
+ assertTrue(r.sent.size() > before,
+ "the checkpoint stayed held behind an arrival that had already expired");
+ Continuity.setMaxAge(0L);
+ }
+
+ /**
+ * An acknowledged origin stays refused even after the in-memory map has evicted it. lastSeen
+ * and durableSeen are bounded independently and hold different sets -- every arrival versus
+ * only the completed ones -- so a busy relay can push an acknowledged origin out of lastSeen
+ * while its durable mark remains. Consulting only lastSeen let the duplicate through and ran
+ * the application's listeners a second time, which is exactly what the durable mark is for.
+ */
+ @EdtTest
+ public void anAcknowledgedOriginIsRefusedAfterItsDedupEntryIsEvicted() {
+ AppState handled = fromElsewhere("dealt with", 5L);
+ Continuity.acknowledge(handled);
+
+ // The crowd has to be arrivals that were ADMITTED and never COMPLETED, which is the
+ // difference between the two maps this test is about. A provider that throws is the
+ // cheapest way to say that: every one of these enters lastSeen on admission and none of
+ // them earns a durable mark.
+ //
+ // They used to be empty states, which is a tombstone -- and a consumed tombstone is a
+ // completed arrival, so once tombstones started being marked durably the crowd competed
+ // for the durable map too and evicted the acknowledgement this test exists to protect.
+ // The failure was real and the test was the thing that was wrong: its own premise is
+ // "every arrival versus only the completed ones", and an empty state is both.
+ Continuity.setStateProvider(new StateProvider() {
+ public Map saveState() {
+ return new HashMap();
+ }
+
+ public void restoreState(Map payload) {
+ throw new IllegalStateException("nothing here completes");
+ }
+ });
+ for (int i = 0; i < 90; i++) {
+ Map