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("")) { + // "" 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("' || 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 "` 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("Otherx", + 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 = "CN1ContinuityActivityTypecom.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 = "NSUserActivityTypescom.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 = "NSUserActivityTypesa"; + + 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 = "NSUserActivityTypescom.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 = "NSUserActivityTypesnot 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 = "NSUserActivityTypesa"; + + assertEquals(inject, IPhoneBuilder.expandEmptyUserActivityArray(inject)); + } + + /** Another key's empty array is none of this method's business. */ + @Test + void anUnrelatedEmptyArrayIsNotRewritten() { + String inject = "NSUserActivityTypesa" + + "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 = "" + + "NSUserActivityTypesa"; + + 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 = "NSUserActivityTypesnot an array" + + "SomethingElsekeep"; + + 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" + + "UUID0f7ac3c1-4d0e-4e8a-9d1f-8b6a2c5e7d90\n" + + "ExpirationDate2099-01-01T00:00:00Z\n" + + "DeveloperCertificatesZm9v\n" + + "Entitlements" + + "application-identifier" + + "ABCD1234.com.example.app" + + kvStore + + "get-task-allow\n" + + ""; + byte[] payload = plist.getBytes("UTF-8"); + // The parser skips a binary preamble, exactly as a real signed profile carries one. + byte[] wrapped = new byte[payload.length + 24]; + for (int i = 0; i < 16; i++) { + wrapped[i] = (byte) (0x80 + i); + } + System.arraycopy(payload, 0, wrapped, 16, payload.length); + File f = tmp.newFile(name + ".mobileprovision"); + OutputStream out = new FileOutputStream(f); + try { + out.write(wrapped); + } finally { + out.close(); + } + return f; + } + + private Properties settings(File appProfile) throws Exception { + Properties p = new Properties(); + p.setProperty("codename1.packageName", "com.example.app"); + p.setProperty(IOSProvisioningPreflight.provisioningProfileSettingKey(true), + appProfile.getAbsolutePath()); + p.setProperty("codename1.arg.ios.continuity.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 payload = new HashMap(); + payload.put("crowd", Integer.valueOf(i)); + Continuity.deliver(new AppState() + .setPayload(payload) + .setDeviceId("crowd-" + i) + .setSequence(i + 1) + .setTimestamp(System.currentTimeMillis())); + } + flushSerialCalls(); + + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + Continuity.deliver(handled); + flushSerialCalls(); + + assertEquals(0, seen[0], + "a state from an acknowledged origin was delivered again once the in-memory " + + "dedup entry had been evicted, so its side effects run twice"); + } + + /** + * A tombstone supersedes work still parked from the same origin. An empty state is that + * device saying it has nothing any more, so an older state of its own that is waiting on the + * user no longer exists -- offering it keeps proposing work the origin cleared, and the + * publication hold keeps this device's checkpoints off the relay behind it. + */ + @EdtTest + public void aTombstoneClearsWorkStillParkedFromTheSameOrigin() { + 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.deliver(fromElsewhere("waiting on the user", 111L)); + flushSerialCalls(); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), "the checkpoint should be held while it is parked"); + + // The same origin says it has nothing now. + Continuity.deliver(new AppState() + .setDeviceId("some-other-device") + .setSequence(112L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "some-other-device".equals(left.getDeviceId()), + "work the origin cleared with a tombstone is still being offered"); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held behind work the origin had already cleared"); + } + + /** + * A tombstone also clears work that origin left on the SHELF. + * + *

The shelf holds an arrival displaced by one from another device, and every way an + * arrival ends has to reach it: an origin saying it has nothing any more settles its shelved + * state exactly as it settles its parked one. Missing this promoted work the origin had + * already cleared the moment the slot emptied -- the same "continue what you were doing?" + * over nothing at all that the tombstone rule exists to prevent, just deferred by one + * arrival.

+ */ + @EdtTest + public void aTombstoneClearsWorkAnOriginLeftOnTheShelf() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Continuity.deliver(foreign("phone", 111L)); + flushSerialCalls(); + // The tablet's takes the slot, so the phone's goes to the shelf. + Continuity.deliver(foreign("tablet", 1L)); + flushSerialCalls(); + // And then the phone says it has nothing. + Continuity.deliver(new AppState() + .setDeviceId("phone") + .setSequence(112L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "the tablet's arrival is not on offer"); + assertEquals("tablet", onOffer.getDeviceId(), "the wrong arrival is on offer"); + Continuity.acknowledge(onOffer); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "work the phone cleared with a tombstone was promoted off the shelf once the " + + "slot emptied, so the user is offered work that no longer exists"); + } + + /** + * A shelved arrival holds relay publication, exactly as a parked one does. + * + *

The hold exists because the arrival's only copy is in this process: the relay keeps one + * document per user, so publishing over it while a state waits on the user loses that state + * for good. That is MORE true of a shelved arrival than a parked one -- the port has already + * been told the framework took it, so nothing else has a copy at all -- and a hold that + * covered only the slot let the next checkpoint overwrite it the moment the slot emptied.

+ */ + @EdtTest + public void aShelvedArrivalHoldsRelayPublication() { + 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.deliver(foreign("phone", 111L)); + flushSerialCalls(); + Continuity.deliver(foreign("tablet", 1L)); + flushSerialCalls(); + + // The user deals with the tablet's, which empties the SLOT. The phone's is still on the + // shelf, so the hold has to survive. + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "the tablet's arrival is not on offer"); + Continuity.acknowledge(onOffer); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), + "the checkpoint went out over the relay's only copy of the phone's arrival, " + + "which is waiting on the shelf and exists nowhere else"); + + // And settling that one lets it go, or the hold would never end. + AppState fromShelf = Continuity.getRestorableState(); + assertNotNull(fromShelf, "the phone's arrival was not promoted off the shelf"); + assertEquals("phone", fromShelf.getDeviceId(), "the wrong arrival came off the shelf"); + Continuity.acknowledge(fromShelf); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the publication stayed held after every arrival had been settled"); + } + + /** + * A shelved arrival expires like a parked one, and lets the publication go when it does. + * + *

getMaxAge() is measured when the question is asked, not when the state was displaced -- + * an arrival that was fresh when another device took the slot can be long stale by the time + * that slot frees up. Exempting the shelf would have reintroduced the expiry hole on the one + * path where the wait is longest, and left the hold in place for ever behind it.

+ */ + @EdtTest + public void aShelvedArrivalExpiresLikeAParkedOne() { + 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 phone = foreign("phone", 111L); + AppState tablet = foreign("tablet", 1L); + Continuity.parkForTest(phone); + Continuity.parkForTest(tablet); + // The user deals with the tablet's, so the SLOT is empty and only the shelf is holding. + Continuity.acknowledge(tablet); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), + "the shelved arrival is not holding the checkpoint back, so there is no hold for " + + "expiry to release and the rest of this proves nothing"); + + // Now it ages out -- and NOTHING asks for a restorable state. An application that never + // calls getRestorableState() is the case the slot's own expiry cannot cover: asking is + // what discards a parked state, so a shelf that expired only on the way past would have + // withheld this device's checkpoints for the rest of the process. + Continuity.setMaxAge(1L); + Continuity.checkpoint(); + pause(250L); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held behind a shelved arrival that had already expired, " + + "and nothing was ever going to ask for it"); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "an expired shelved arrival was still promoted and offered"); + Continuity.setMaxAge(0L); + } + + /** + * The shelf is bounded, and drops the OLDEST when it overflows. + * + *

The shelf holds whole states, payloads included, and the device ids that key it come off + * the wire -- so an unbounded one lets whatever is on the other end of the relay decide how + * much memory this process uses. Keeping the most recent arrivals is the trade: the newest + * work is the work the user is most likely to still want.

+ */ + @EdtTest + public void theShelfIsBoundedAndDropsTheOldest() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + // Explicit, strictly increasing timestamps: several foreign() calls can land in one + // millisecond, and "oldest" would then be whichever the iterator reached first. + long base = System.currentTimeMillis() - 20000L; + for (int i = 0; i < 12; i++) { + Map payload = new HashMap(); + payload.put("note", "device " + i); + Continuity.parkForTest(new AppState() + .setPayload(payload) + .setDeviceId("device-" + i) + .setSequence(1L) + .setTimestamp(base + i)); + } + + // Drain everything the shelf and the slot are still holding. + Set offered = new HashSet(); + for (int i = 0; i < 20; i++) { + AppState next = Continuity.getRestorableState(); + if (next == null || !String.valueOf(next.getDeviceId()).startsWith("device-")) { + break; + } + offered.add(next.getDeviceId()); + Continuity.acknowledge(next); + } + + assertEquals(9, offered.size(), + "the shelf kept " + offered.size() + " arrivals beside the slot's, so twelve " + + "devices on one account can grow it without limit: " + offered); + assertTrue(offered.contains("device-11"), "the newest arrival was not kept"); + assertFalse(offered.contains("device-0"), + "the oldest arrival was kept and something newer was dropped instead"); + } + + /** + * A state with no origin never reaches the shelf. + * + *

Everything the shelf does -- supersede, settle, promote -- is keyed by device id, and a + * state that does not say where it came from cannot take part in any of it. Keying one under + * a null origin looked harmless and was not: with two unidentified states in a row, the same + * call that shelved the first looked a null origin straight back up, pulled it out again, and + * -- its sequence being the higher of the two -- handed it the slot and dropped the arrival + * that had just displaced it. The loss this mechanism exists to prevent, produced by the + * mechanism.

+ * + *

What reaches placeOnOffer() without an id is a state the application built and handed to + * restore(), which it still holds its own reference to -- never a continuation, which always + * carries one. So it is left out of the shelf entirely rather than given a synthetic key.

+ */ + @EdtTest + public void aStateWithNoOriginNeverReachesTheShelf() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + // Two of the application's own states in a row -- restore() re-offers one it could not + // apply -- the first with the HIGHER sequence, which is what made the swap visible. + Map first = new HashMap(); + first.put("note", "displaced"); + Continuity.parkForTest(new AppState() + .setPayload(first).setSequence(99L) + .setTimestamp(System.currentTimeMillis())); + Map second = new HashMap(); + second.put("note", "arrived last"); + Continuity.parkForTest(new AppState() + .setPayload(second).setSequence(5L) + .setTimestamp(System.currentTimeMillis())); + + AppState onOffer = Continuity.getRestorableState(); + assertNotNull(onOffer, "nothing is on offer at all"); + assertEquals("arrived last", onOffer.getPayload().get("note"), + "the unidentified state was pulled back out of the shelf it had just been put " + + "into, and its higher sequence took the slot from the arrival that " + + "displaced it"); + } + + /** + * An arrival whose cold-launch window has closed is not dispatched into a running app. + * + *

The wait is documented as bounded, and it was bounded in only one of the two places it + * has to be. The waiter asked the event thread whether a window had appeared through an + * UNTIMED callSeriallyAndWait, so a thread busy building its first forms blocked the question + * itself and the loop could not recheck its own deadline; and the hand-back is a callSerially, + * so even a loop that ended on time runs this half whenever the event thread next gets to it. + * Either way the continuation could be applied minutes in -- replacing whatever the user had + * started doing, which is the interruption the bound exists to rule out.

+ * + *

It stays PARKED rather than being dropped: nothing dealt with it, so the application can + * still take it through getRestorableState() at a moment of its own choosing.

+ */ + @EdtTest + public void anArrivalPastTheColdLaunchWindowIsNotDispatched() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + final int[] dispatched = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + dispatched[0]++; + return true; + } + }); + + Continuity.parkForTest(fromElsewhere("waited out the window", 140L)); + Continuity.drainParkedPastTheWindowForTest(); + + assertEquals(0, dispatched[0], + "an arrival was applied after its bounded window had closed, so it replaces " + + "whatever the user started doing while the event thread was busy"); + AppState still = Continuity.getRestorableState(); + assertNotNull(still, "the arrival was dropped rather than left on offer"); + assertEquals(140L, still.getSequence(), + "the arrival that outlived the window is no longer the one on offer"); + + // And the ordinary drain still dispatches, or this guard would have turned the cold-launch + // hand-over off altogether. + Continuity.drainParkedForTest(); + assertEquals(1, dispatched[0], + "the in-window drain stopped dispatching, so nothing is ever handed over"); + } + + /** + * Acknowledging a SHELVED arrival directly releases the publication hold it was keeping. + * + *

A listener that defers two arrivals keeps its own references to both. Settling the + * parked one empties the slot; settling the shelved one is then done by handing that + * reference to acknowledge(), without promoting it through getRestorableState() first, which + * is the documented handle-it-yourself shape. The release beside every settle was keyed to + * the SLOT emptying, so on that path nothing let the publisher go and the queued checkpoint + * sat until some unrelated later one happened to start it.

+ */ + @EdtTest + public void acknowledgingAShelvedArrivalReleasesItsPublicationHold() { + 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 phone = foreign("phone", 1L); + AppState tablet = foreign("tablet", 1L); + Continuity.parkForTest(phone); + Continuity.parkForTest(tablet); + + // The slot's is settled first, which is the ordinary half. + Continuity.acknowledge(tablet); + Continuity.checkpoint(); + pause(250L); + assertEquals(before, r.sent.size(), + "the shelved arrival is not holding the checkpoint, so there is no hold for the " + + "settle below to release and the rest of this proves nothing"); + + // And now the SHELVED one, straight from the listener's own reference. Nothing calls + // getRestorableState(), so the slot never changes. + Continuity.acknowledge(phone); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitAnySince(before); + } + }); + assertTrue(r.sent.size() > before, + "the checkpoint stayed held after the last arrival was settled, because the " + + "release is keyed to the slot emptying and this one came off the shelf"); + } + + /** + * The cold-launch drain hands over EVERY arrival it was holding, not just the slot's. + * + *

Two devices can both reach the callback before the first form exists -- which is the + * situation the wait exists for -- 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, reachable only if the application called + * getRestorableState() by hand, and holding every relay publication behind it meanwhile.

+ * + *

The third path in this class to need it, after the pre-enable drain and the settles. + * A second holder means every path that empties either one has to deal with both.

+ */ + @EdtTest + public void theColdLaunchDrainHandsOverEveryArrivalItHeld() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + final List seen = new ArrayList(); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen.add(state.getDeviceId()); + return true; + } + }); + + long base = System.currentTimeMillis() - 4000L; + Map first = new HashMap(); + first.put("note", "from the phone"); + Continuity.parkForTest(new AppState() + .setPayload(first).setDeviceId("phone").setSequence(1L).setTimestamp(base)); + Map second = new HashMap(); + second.put("note", "from the tablet"); + Continuity.parkForTest(new AppState() + .setPayload(second).setDeviceId("tablet").setSequence(1L) + .setTimestamp(base + 1000L)); + + Continuity.drainParkedForTest(); + flushSerialCalls(); + + assertTrue(seen.contains("tablet"), "the slot's arrival was never handed over: " + seen); + assertTrue(seen.contains("phone"), + "the arrival displaced onto the shelf before the first form existed was never " + + "handed over, so nothing was ever going to dispatch it and it went on " + + "holding every relay checkpoint behind it: " + seen); + } + + /** + * A session ended BEFORE the restore installed anything leaves the pre-restore history alone. + * + *

A route factory can end the session on its very first call, before restoreStack() has put + * anything in place. The live stack is then the history the user already had, and disable() is + * not a logout -- there is nothing here that licenses destroying it.

+ * + *

The subsequence test alone could not see this: a pre-restore stack can coincide with a + * prefix of what was requested, which is ordinary rather than contrived -- live /home against + * a requested /home,/detail -- and it read as restoration-owned. Asking whether the stack + * changed at all is what separates "installed a subset" from "installed nothing".

+ */ + @EdtTest + public void aSessionEndedBeforeAnythingWasInstalledKeepsThePreRestoreHistory() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + final Form home = new Form("home"); + home.show(); + flushSerialCalls(); + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle(url); + return f; + } + }); + try { + // The history the user already has, put there the ordinary way. + Navigation.navigate("/home"); + flushSerialCalls(); + assertEquals(1, Navigation.getStack().size(), "the fixture never built a stack"); + + // Now a restore whose FIRST factory call ends the session before installing anything. + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + Continuity.disable(); + return null; + } + }); + Map payload = new HashMap(); + payload.put("draft", "from the other device"); + Continuity.restore(new AppState() + .setPayload(payload) + .setRoutes(java.util.Arrays.asList("/home", "/detail")) + .setDeviceId("some-other-device") + .setSequence(180L) + .setTimestamp(System.currentTimeMillis())); + flushSerialCalls(); + + assertEquals(1, Navigation.getStack().size(), + "the pre-restore history was cleared by an abort, even though this restore " + + "installed nothing -- disable() is not a logout: " + + Navigation.getStack()); + } finally { + Navigation.setDispatcher(null); + Navigation.clearStack(); + } + } + + /** + * Enabling a session reads the relay, so a login finds the new account's work. + * + *

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 + * still installed and nothing asking it anything: the account that just signed in did not see + * its own state from another device until the application happened to call pollRelay() or the + * app was resumed -- and Android's resume poll is a different event that a login completed in + * the foreground never fires, with no automatic one on iOS at all.

+ * + *

Unlike the disable() that clear() needs, there is nothing to leave to the application + * here: a session beginning is the moment to read the relay, and this is the code that knows + * one began.

+ */ + @EdtTest + public void enablingASessionReadsTheRelay() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + final GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + r.release(); + pause(300L); + + // The documented logout, which ends the session and drops anything in flight. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + final int before = r.fetches(); + + // And the login. + Continuity.enable(); + pause(400L); + + assertTrue(r.fetches() > before, + "enabling a new session never read the relay, so the account that just signed in " + + "does not see its own work from another device until something else " + + "happens to poll"); + } + + /** + * The simulator bridge hands out a real copy of what it advertised, nested containers too. + * + *

AppState already decided this for its own snapshot -- a shallow copy left it sharing the + * application's lists and maps, and a snapshot has to be a snapshot -- and this bridge + * contradicted it at the three places it passes a payload across. What it advertises is what + * the simulator shows and what tests assert on, so a caller reaching into a nested list was + * editing the record of what had been published, and the next simulateArrival() delivered the + * edit instead of the checkpoint.

+ * + *

A double that shares state it says it copied is worse than one that is plainly wrong, + * because what it breaks is the test's ability to notice.

+ */ + @EdtTest + public void theSimulatorBridgeCopiesNestedPayloadContainers() { + RecordingProvider provider = new RecordingProvider(); + List lines = new ArrayList(); + lines.add("the original draft"); + provider.saved.put("lines", lines); + Continuity.setStateProvider(provider); + Continuity.routeStackChanged(); + Continuity.checkpoint(); + flushSerialCalls(); + + Map published = bridge.getPublishedInfo(); + assertNotNull(published, "nothing was advertised, so this test has nothing to copy"); + + // Reach into a NESTED container of what was handed back. + Object payload = published.get("payload"); + assertTrue(payload instanceof Map, "the advertised payload is not a map: " + payload); + Object got = ((Map) payload).get("lines"); + assertTrue(got instanceof List, "the nested list did not survive the round trip: " + got); + ((List) got).add("an edit the caller made to its own copy"); + + Map again = bridge.getPublishedInfo(); + Object second = ((Map) again.get("payload")).get("lines"); + assertEquals(1, ((List) second).size(), + "the caller's edit reached the advertised payload, so the bridge shares the " + + "container it says it copied: " + second); + } + + /** + * Installing a relay reads it ONCE, however the session happened to start. + * + *

setRelay() enables continuity when it is the first thing to, and enable() polls as the + * last thing it does -- so an unconditional poll here read twice: the second call found a poll + * already running, set pollAgain, and pollFinished() issued another GET. Two sequential reads + * for one setup, and a relay whose document changed between them handed the application two + * different snapshots and navigated the user twice.

+ * + *

Both orders are checked, because the fix turns on which of the two started the session + * and getting it backwards would leave a newly installed relay unread.

+ */ + @EdtTest + public void installingARelayReadsItExactlyOnce() { + // setRelay() is what starts the session here: no provider has been set. + final GatedRelay first = new GatedRelay(); + Continuity.setRelay(first); + // awaitFetched, not awaitEntered: the gate is on PUBLISH, and no provider is set here so + // nothing ever publishes. Waiting on the wrong one hangs rather than fails. + awaitOffEdt(new Runnable() { + public void run() { + first.awaitFetched(1); + } + }); + // And then a moment longer, which is the whole point: a second GET queued behind the + // first arrives after it, so an assertion made the instant the first lands cannot see it. + pause(500L); + assertEquals(1, first.fetches(), + "installing a relay read it more than once: enable() polls when it starts the " + + "session, and the poll beside it queued a second GET behind the first"); + + // And the other order: already enabled, so enable() does nothing and this call has to be + // the one that asks the new endpoint. + final GatedRelay second = new GatedRelay(); + Continuity.setRelay(second); + awaitOffEdt(new Runnable() { + public void run() { + second.awaitFetched(1); + } + }); + pause(500L); + assertEquals(1, second.fetches(), + "a relay installed while continuity was already running was never read, so the " + + "new endpoint's state is not discovered until something else polls"); + } + + /** + * A listener that ends the session during the cold-launch drain stops the rest of it. + * + *

dispatch() hands each state to application code, and that code may end the session -- a + * listener finding the account signed out calls clear(), which is the documented shape. + * Everything still in the drain arrived BEFORE that, so continuing offers the previous + * session's work to the one that replaced it. 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 pre-enable drain has had this guard since it was written; this loop was added later + * without it.

+ */ + @EdtTest + public void aListenerThatEndsTheSessionStopsTheColdLaunchDrain() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + final List seen = new ArrayList(); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen.add(state.getDeviceId()); + // The account is signed out. This is the documented logout from a listener. + Continuity.clear(); + return true; + } + }); + + long base = System.currentTimeMillis() - 4000L; + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("from the phone")) + .setDeviceId("phone").setSequence(1L).setTimestamp(base)); + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("from the tablet")) + .setDeviceId("tablet").setSequence(1L).setTimestamp(base + 1000L)); + + Continuity.drainParkedForTest(); + flushSerialCalls(); + + assertEquals(1, seen.size(), + "the drain carried on after the listener ended the session, so work from before " + + "the logout was offered to the session that replaced it: " + seen); + } + + /** + * A negative timestamp does not outrank the expiry the application configured. + * + *

The codec refuses one 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 a state it built. All of those reach isTooOld(), where a "positive timestamp" guard + * read a negative one as the documented "carries no time" and exempted it from every maxAge -- + * an expired checkout restorable for the life of the install, under exactly the setting that + * exists to stop it.

+ */ + @EdtTest + public void aNegativeTimestampIsNotExemptFromMaxAge() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + Continuity.setMaxAge(60000L); + try { + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("a relay that returns nonsense")) + .setDeviceId("phone").setSequence(1L).setTimestamp(-1L)); + + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "a state whose timestamp cannot prove its freshness was offered anyway, so it " + + "is exempt from the maxAge that was configured to expire it"); + + // And the extreme, which is the value the explicit refusal is actually needed for. + // For an ordinary negative the subtraction already answers "too old" -- now minus a + // negative is enormous -- but Long.MIN_VALUE OVERFLOWS it back to a negative, so the + // comparison reads as fresh and the state becomes immortal by arithmetic rather than + // by the guard that used to exempt it. + Continuity.parkForTest(new AppState() + .setPayload(payloadWith("the extreme of the same nonsense")) + .setDeviceId("tablet").setSequence(1L).setTimestamp(Long.MIN_VALUE)); + + AppState extreme = Continuity.getRestorableState(); + assertFalse(extreme != null && "tablet".equals(extreme.getDeviceId()), + "Long.MIN_VALUE overflowed the age subtraction back to a negative, so the " + + "state read as fresh and no maxAge can ever expire it"); + } finally { + Continuity.setMaxAge(0L); + } + } + + /** + * An arrival that landed while continuity was OFF does not join the session that follows. + * + *

clear() and disable() each advance the generation and enable() did not, so the interval + * between a logout and the login after it carried the same generation as the session that + * followed. A continuation reaching the callback during that interval captured that generation + * and queued its decision; the decision then ran after enable() had set the flag back, found + * its generation still current, and admitted the signed-out account's work into the account + * that had just signed in. The callback had already answered "claimed", so the bridge was + * entitled to have dropped the only other copy.

+ * + *

The queued decision surviving across enable() is the whole scenario, so the test stages + * exactly that: deliver while off, enable, and only then let the event thread run.

+ */ + @EdtTest + public void anArrivalFromTheOffPeriodDoesNotJoinTheNextSession() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + flushSerialCalls(); + + // The documented logout. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + provider.restored = null; + + // A continuation lands while continuity is off. The callback claims it and queues the + // decision, which has NOT run yet. + final ContinuityCallback c = Continuity.callbackForTest(); + Map payload = new HashMap(); + payload.put("note", "the signed-out account's work"); + final Map wire = StateCodec.toMap(new AppState() + .setPayload(payload).setDeviceId("phone").setSequence(9L) + .setTimestamp(System.currentTimeMillis())); + final boolean[] claimed = new boolean[1]; + // A plain thread joined from here, NOT invokeAndBlock. The callback only queues its + // decision when it is called off the event thread -- on it, the decision is taken inline + // and this scenario cannot arise -- and invokeAndBlock keeps the event thread pumping, so + // the queued decision would run before the enable() below instead of across it. Joining + // holds the event thread still, which is what leaves the decision waiting. + Thread bridgeThread = new Thread(new Runnable() { + public void run() { + claimed[0] = c.continuationReceived(Continuity.getActivityType(), wire); + } + }, "bridge-off-edt"); + bridgeThread.start(); + try { + bridgeThread.join(4000L); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + } + assertTrue(claimed[0], + "the arrival was not claimed off-EDT, so nothing was queued and this test stages " + + "nothing"); + + // The login happens before the queued decision gets a turn. + Continuity.enable(); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "work that arrived while continuity was off was admitted into the session that " + + "followed, so the signed-out account's state reached the new one"); + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "the off-period arrival is on offer to the new session"); + } + + /** + * An ordinary logout does not leave the account's screen held in a static. + * + *

clear() records the current form for one comparison -- applicationChoseTheScreen(), + * which exists for a route factory or a show callback that ends the session from inside + * restoreStack(). Outside that window nothing ever reads it, and it was recorded anyway: a + * strong reference to the signed-out account's whole component tree, and to whatever the + * application hung off it, reachable through a static for the rest of the next session.

+ * + *

The sibling test above -- a login form the logout callback chose surviving the undo -- + * is the other half of this: the comparison still has what it needs when a session really + * does end mid-restore.

+ */ + @EdtTest + public void anOrdinaryLogoutHoldsNoFormAfterwards() { + Continuity.setStateProvider(new RecordingProvider()); + new Form("the signed-out account's screen").show(); + flushSerialCalls(); + + Continuity.clear(); + + assertNull(Continuity.formAtSessionEndForTest(), + "the logout kept a reference to the account's screen, so its whole component " + + "tree stays reachable through a static for the next session"); + + // disable() records it on the same terms, and is the other half of the documented logout. + new Form("still nothing restoring").show(); + flushSerialCalls(); + Continuity.disable(); + + assertNull(Continuity.formAtSessionEndForTest(), + "disable() kept a reference to the screen outside any restore"); + } + + /** + * clear() empties the shelf, not only the slot. + * + *

clear() is a logout: nothing from before it survives. A shelved arrival is state from + * the previous account sitting in memory, so leaving it there would let the login that + * follows promote the signed-out user's work into the next account -- the same leak that + * clearing only this class's slot and not the port's already produced once.

+ */ + @EdtTest + public void clearEmptiesTheShelfAsWellAsTheSlot() { + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Continuity.deliver(foreign("phone", 111L)); + flushSerialCalls(); + Continuity.deliver(foreign("tablet", 1L)); + flushSerialCalls(); + + Continuity.clear(); + + AppState left = Continuity.getRestorableState(); + assertNull(left, + "an arrival from before the logout survived clear() on the shelf, so the login " + + "that follows restores the previous account's work"); + } + + /** + * A logout between queueing a publish and the worker reaching the network must stop the + * request. RestStateRelay resolves getToken() INSIDE publish(), so a quick logout and login + * would otherwise send the previous account's state under the next account's credentials -- + * and clear() documents that nothing follows it. + */ + @EdtTest + public void aLogoutStopsAPublishThatHasNotReachedTheNetwork() { + 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); + + // Queue a publish, then sign out before the worker can get to the wire. The worker + // confirms its session on the event thread, and this test body IS the event thread, so + // the confirmation cannot run until after clear() below. + Continuity.checkpoint(); + final int before = r.sent.size(); + Continuity.clear(); + pause(500L); + + assertEquals(before, r.sent.size(), + "a state queued before the logout was sent afterwards, which with a token " + + "resolved inside publish() means the previous account's work went out " + + "under the next account's credentials"); + } + + /** + * A fetch that failed is not an empty relay. Collapsing a timeout into the same null said the + * read had succeeded and found nothing -- which is what makes overwriting the single document + * safe -- so a queued checkpoint replaced another device's state this one had never read. + */ + @EdtTest + public void aFailedFetchDoesNotAuthoriseAPublish() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + final ThrowingFetchRelay r = new ThrowingFetchRelay(); + Continuity.setRelay(r); + + Continuity.checkpoint(); + Continuity.pollRelay(); + pause(600L); + + assertTrue(r.fetches() > 0, "the relay should have been asked"); + assertEquals(0, r.published(), + "a checkpoint was published on the strength of a fetch that failed, so another " + + "device's state can be overwritten without ever having been read"); + } + + /** A relay whose fetch always fails, which is what a timeout looks like. */ + static class ThrowingFetchRelay implements StateRelay { + private final java.util.concurrent.atomic.AtomicInteger fetched = + 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(); + } + + public AppState fetch() throws java.io.IOException { + fetched.incrementAndGet(); + throw new java.io.IOException("the endpoint timed out"); + } + + int fetches() { + return fetched.get(); + } + + int published() { + return posts.get(); + } + } + + /** Storage whose writes always fail, which is what a full disk looks like. */ + static class RefusingStorage extends Storage { + @Override + public boolean writeObject(String name, Object o) { + return false; + } + } + + /** + * An applied state has to reach local storage, and a payload-only one is the case that proves + * it. noteActedOn() is durable -- once it runs the relay's copy is refused for good -- so a + * state that was acknowledged and never written is lost outright if the process dies before + * anything else checkpoints. An app that does not use @Route has nothing else that + * checkpoints, which is exactly the app this shape of state belongs to. + */ + @EdtTest + public void aPayloadOnlyRestoreIsWrittenToStorage() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + + AppState payloadOnly = fromElsewhere("stored payload", 12L); + payloadOnly.setRoutes(new ArrayList()); + + assertFalse(Continuity.restore(payloadOnly), "a route-less state shows no form"); + + // What the next cold start would find: nothing is parked, so this is storage answering. + AppState stored = Continuity.getRestorableState(); + assertNotNull(stored, + "the applied payload-only state was acknowledged but never written to storage"); + assertEquals("stored payload", stored.getPayload().get("note"), + "storage holds a different state than the one that was applied"); + } + + /** + * And the parked slot is released on application, not on a form appearing. Gating it on the + * return value kept a payload-only arrival parked for ever, so every restore() re-applied it. + */ + @EdtTest + public void aPayloadOnlyParkedStateIsNotOfferedTwice() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(false); + + AppState payloadOnly = fromElsewhere("parked payload", 9L); + payloadOnly.setRoutes(new ArrayList()); + Continuity.deliver(payloadOnly); + flushSerialCalls(); + + assertFalse(Continuity.restore(), "a route-less state shows no form"); + + // Storage legitimately holds it now -- an applied state IS the local checkpoint, which is + // what the next cold start should come back to -- so asking getRestorableState() alone + // cannot tell the parked slot from the stored copy. Remove the stored copy and ask again: + // whatever answers now can only be the parked slot, and it has to be empty. + Storage.getInstance().deleteStorageFile(Continuity.STORAGE_KEY); + AppState left = Continuity.getRestorableState(); + assertNull(left, "the parked state was applied and must not still be waiting"); + } + + /** + * A state that is still QUEUED when the user signs out is never sent. The worker is held + * inside its first request, a second checkpoint queues behind it, and clear() then empties + * the queue -- so when the worker is released it finds nothing of the old session to send. + * + *

The boundary this does NOT cover is a request already on the wire. Holding the relay + * inside publish is exactly that case, and clear()'s own documentation says it cannot recall + * one -- which is why the first state is expected to arrive and only the second must not.

+ */ + @EdtTest + public void aStateStillQueuedAtLogoutIsNeverSent() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("n", Integer.valueOf(1)); + Continuity.setStateProvider(provider); + GatedRelay r = new GatedRelay(); + Continuity.setRelay(r); + + Continuity.checkpoint(); + awaitOffEdt(new Runnable() { + public void run() { + r.awaitEntered(); + } + }); + final long inFlight = Continuity.getRestorableState().getSequence(); + + // Queued behind the request the worker is holding. + provider.saved.put("n", Integer.valueOf(2)); + Continuity.checkpoint(); + long queued = Continuity.getRestorableState().getSequence(); + assertTrue(queued > inFlight, "the second checkpoint did not advance the sequence"); + + Continuity.clear(); + r.release(); + // The positive signal FIRST: the worker got past the gate and finished the request it was + // holding. Asserting the absence of `queued` before that proved nothing at all. + awaitOffEdt(new Runnable() { + public void run() { + r.awaitSent(inFlight); + r.settle(); + } + }); + + assertFalse(r.sent.contains(Long.valueOf(queued)), + "a state queued before logout was published after it: " + r.sent); + } + + /** + * Blocks on entry to publish so a test can act while a state is dequeued but unsent. Records + * only what it was actually asked to send AFTER being released. + */ + static class GatedRelay implements StateRelay { + final List sent = java.util.Collections.synchronizedList(new ArrayList()); + private final java.util.concurrent.CountDownLatch entered = + new java.util.concurrent.CountDownLatch(1); + private final java.util.concurrent.CountDownLatch gate = + new java.util.concurrent.CountDownLatch(1); + + public void publish(AppState state) { + entered.countDown(); + try { + gate.await(3, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + sent.add(Long.valueOf(state.getSequence())); + } + + private final java.util.concurrent.atomic.AtomicInteger fetched = + new java.util.concurrent.atomic.AtomicInteger(); + + public AppState fetch() { + fetched.incrementAndGet(); + return null; + } + + /** How many GETs have actually reached the endpoint. */ + int fetches() { + return fetched.get(); + } + + /** Waits until at least `count` GETs have run, so a deferred poll can be seen to land. */ + void awaitFetched(int count) { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline && fetched.get() < count) { + sleepBriefly(); + } + } + + void awaitEntered() { + try { + entered.await(3, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + void release() { + gate.countDown(); + } + + /// Waits until `sequence` has been sent -- a POSITIVE signal that the worker resumed. + /// + /// The test that uses this asserts an ABSENCE (the state queued before logout must not go + /// out), and an absence asserted too early is not evidence of anything: the publish simply + /// had not happened yet. A bare sleep gave exactly that, so the test could pass without + /// the code under it ever running. + /** + * Waits until more than `count` states have been sent. + * + * Deliberately shorter than the harness's own 5s limit. At 5000 a regression raced it and + * the test reported "FormTest timed out" instead of the assertion that explains what + * broke -- a failure message that sends the next reader hunting a hung event thread. + */ + void awaitAnySince(int count) { + long deadline = System.currentTimeMillis() + 2500L; + while (System.currentTimeMillis() < deadline && sent.size() <= count) { + sleepBriefly(); + } + } + + void awaitSent(long sequence) { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline) { + if (sent.contains(Long.valueOf(sequence))) { + return; + } + sleepBriefly(); + } + } + + /// A bounded pause after the positive signal, so a worker that WOULD take the next state + /// has had its chance. A bound, not a proof -- but the proof is the assertion above it. + void settle() { + sleepBriefly(); + sleepBriefly(); + } + + private void sleepBriefly() { + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } + + /** Records the sequence of everything the relay is handed, slowly enough to overlap. */ + static class OrderRecordingRelay implements StateRelay { + final List published = + java.util.Collections.synchronizedList(new ArrayList()); + + public void publish(AppState state) { + try { + Thread.sleep(15); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + published.add(Long.valueOf(state.getSequence())); + } + + public AppState fetch() { + return null; + } + + /// Waits until `sequence` has actually been published. + /// + /// NOT "until the relay goes quiet", which is what this did and why it failed about one + /// run in five. Quiet is not finished: the publisher coalesces while the EDT is still + /// checkpointing, so a gap longer than the idle window happens naturally on a loaded + /// machine and was read as settled -- the assertions then ran against a half-delivered + /// list and reported the relay's last value as an older checkpoint. Waiting for the + /// condition the test actually asserts is the only version of this that cannot lie. + void awaitPublished(long sequence) { + long deadline = System.currentTimeMillis() + 10000L; + while (System.currentTimeMillis() < deadline) { + synchronized (published) { + if (!published.isEmpty() + && published.get(published.size() - 1).longValue() >= sequence) { + return; + } + } + try { + Thread.sleep(25); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + // ------------------------------------------------------------------ + // The synced store + // ------------------------------------------------------------------ + + @EdtTest + public void theSyncedStoreRoundTripsAndEnumerates() { + assertTrue(SyncedStore.isSupported()); + + assertTrue(SyncedStore.put("sortOrder", "byDate")); + assertTrue(SyncedStore.put("theme", "dark")); + + assertEquals("byDate", SyncedStore.get("sortOrder", "byName")); + List keys = new ArrayList(Arrays.asList(SyncedStore.keys())); + assertTrue(keys.contains("sortOrder")); + assertTrue(keys.contains("theme")); + + SyncedStore.remove("theme"); + assertEquals("light", SyncedStore.get("theme", "light")); + assertFalse(new ArrayList(Arrays.asList(SyncedStore.keys())).contains("theme")); + } + + /** + * put() used to answer true whenever a store merely existed, so the fallback the guide + * recommends -- write locally when the synced write fails -- could never run and a value the + * store refused was reported saved. + */ + @EdtTest + public void aRefusedSyncedWriteIsReportedAsFailure() { + JavaSEStyleRefusingBridge refusing = new JavaSEStyleRefusingBridge(); + Continuity.setBridge(refusing); + + assertFalse(SyncedStore.put("sortOrder", "byDate"), + "a store that did not take the value must not report success"); + assertEquals("byName", SyncedStore.get("sortOrder", "byName")); + } + + /** And still answers true when the store really did take it. */ + @EdtTest + public void anAcceptedSyncedWriteIsReportedAsSuccess() { + assertTrue(SyncedStore.put("sortOrder", "byDate")); + assertEquals("byDate", SyncedStore.get("sortOrder", "byName")); + } + + /** A store that reports supported and then silently drops every write. */ + static class JavaSEStyleRefusingBridge extends LocalContinuityBridge { + @Override + public boolean syncedStorePut(String key, String value) { + return false; + } + + @Override + public String syncedStoreGet(String key) { + return null; + } + } + + /** + * A key containing a newline is one this API accepts -- the platform store imposes no such + * rule, so the simulation must not either. The newline-delimited index used to read it back + * as two phantom keys, and nothing could then remove the value that was actually stored. + */ + @EdtTest + public void aKeyContainingANewlineSurvivesTheSimulatedIndex() { + assertTrue(SyncedStore.put("multi\nline", "value")); + + List keys = new ArrayList(Arrays.asList(SyncedStore.keys())); + assertTrue(keys.contains("multi\nline"), "the key came back as " + keys); + assertFalse(keys.contains("multi"), "a phantom key appeared: " + keys); + assertEquals("value", SyncedStore.get("multi\nline", "missing")); + + SyncedStore.remove("multi\nline"); + assertFalse(new ArrayList(Arrays.asList(SyncedStore.keys())) + .contains("multi\nline"), "the key could not be removed"); + } + + /** A backslash in a key is the other half of the escaping, and round-trips too. */ + @EdtTest + public void aKeyContainingABackslashSurvivesTheSimulatedIndex() { + assertTrue(SyncedStore.put("back\\slash", "v")); + + List keys = new ArrayList(Arrays.asList(SyncedStore.keys())); + assertTrue(keys.contains("back\\slash"), "the key came back as " + keys); + } + + @EdtTest + public void aChangeMadeElsewhereReachesTheListener() { + CountingStoreListener listener = new CountingStoreListener(); + registered.add(listener); + SyncedStore.addChangeListener(listener); + + bridge.simulateStoreChange(); + flushSerialCalls(); + + assertEquals(1, listener.calls); + } + + /** + * A listener that unregisters itself while being notified is ordinary, and would otherwise + * mutate the list being walked. + */ + @EdtTest + public void aListenerMayUnregisterItselfWhileBeingNotified() { + SyncedStoreListener selfRemoving = new SyncedStoreListener() { + public void storeChanged() { + SyncedStore.removeChangeListener(this); + } + }; + registered.add(selfRemoving); + SyncedStore.addChangeListener(selfRemoving); + + bridge.simulateStoreChange(); + flushSerialCalls(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static AppState fromElsewhere(String note, long sequence) { + Map payload = new HashMap(); + payload.put("note", note); + return new AppState() + .setPayload(payload) + .setDeviceId("some-other-device") + .setSequence(sequence) + .setTimestamp(System.currentTimeMillis()); + } + + private void deliverFromElsewhereAged(String note, long sequence, long timestamp) { + Map payload = new HashMap(); + payload.put("note", note); + AppState state = new AppState() + .setPayload(payload) + .setDeviceId("some-other-device") + .setSequence(sequence) + .setTimestamp(timestamp); + bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(state)); + flushSerialCalls(); + } + + private void deliverFromElsewhere(String note, long sequence) { + Map payload = new HashMap(); + payload.put("note", note); + AppState state = new AppState() + .setPayload(payload) + .setDeviceId("some-other-device") + .setSequence(sequence) + .setTimestamp(System.currentTimeMillis()); + bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(state)); + flushSerialCalls(); + } + + /** A dispatcher that answers any path with a form, however long the path is. */ + static class FakeLongPathDispatcher implements RouteDispatcher { + public Form dispatch(String url) { + Form f = new Form(); + f.setTitle("long"); + return f; + } + } + + /** A bridge that behaves the way the iOS port does: it holds an activity that arrived before + * anything was listening, and offers it the moment a callback is installed, letting go only + * if the callback claims it. LocalContinuityBridge has no such retention, so the case cannot + * be reached through it. */ + static class HoldingBridge extends LocalContinuityBridge { + Map pending; + + @Override + public void setCallback(ContinuityCallback c) { + super.setCallback(c); + if (c == null || pending == null) { + return; + } + Map offered = pending; + if (c.continuationReceived(Continuity.getActivityType(), offered)) { + // Claimed, so the port lets go of it. Declined, and it stays for the next + // callback -- which is the whole behaviour being relied on. + pending = null; + } + } + } + + /** + * clear() empties the port of a held continuation, and does NOT stop the next one. + * + *

Both halves in one test because they are one promise. A logout has to take the previous + * account's held activity with it -- a Handoff that cold-launched a logged-out app sits in the + * port before anything has installed a callback, so clearing only this class's slot cleared + * nothing that existed -- and it has to leave the framework able to receive the arrival that + * belongs to the account now signing in. clear() is a logout, not "continuity off".

+ * + *

The discard window is the setCallback call, which is why ContinuityBridge requires a held + * continuation to be offered before that method returns. A held one reaches the seam by the + * same route a new one does and carries nothing that separates them, so there is no way to + * bind the discard to the cleared session instead -- and any window that outlasted the call + * would start eating the arrivals the second half of this test asserts must survive.

+ */ + @EdtTest + public void clearEmptiesTheHeldContinuationWithoutDeafeningWhatFollows() { + HoldingBridge bridge = new HoldingBridge(); + Continuity.setBridge(bridge); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.setAutoRestore(false); + + Map before = new HashMap(); + before.put("note", "the signed-out account's work"); + bridge.pending = StateCodec.toMap(new AppState() + .setPayload(before) + .setDeviceId("phone").setSequence(1L) + .setTimestamp(System.currentTimeMillis())); + + Continuity.clear(); + flushSerialCalls(); + + assertNull(bridge.pending, + "the port is still holding the signed-out account's continuation, so the enable() " + + "that comes with the next login drains it into that account"); + AppState left = Continuity.getRestorableState(); + assertNull(left, + "the previous account's work survived the logout and is on offer to the next one"); + + // The other half: an arrival AFTER the clear belongs to whoever is signing in now, and a + // discard window that outlasted the drain would swallow it. + Continuity.enable(); + flushSerialCalls(); + Map after = new HashMap(); + after.put("note", "the new account's work"); + // Through the BRIDGE, not deliver(): the discard flag is read in the inbound callback, and + // deliver() is a seam that enters at admit(). Asserting this half over deliver() exercised + // nothing -- a probe that left the window open for ever still passed. + bridge.simulateArrival(Continuity.getActivityType(), StateCodec.toMap(new AppState() + .setPayload(after) + .setDeviceId("tablet").setSequence(1L) + .setTimestamp(System.currentTimeMillis()))); + flushSerialCalls(); + + AppState arrived = Continuity.getRestorableState(); + assertNotNull(arrived, "an arrival after the logout was dropped along with it"); + assertEquals("the new account's work", arrived.getPayload().get("note"), + "the arrival that reached the new session is not the one it was sent"); + } + + /** + * enable() drains EVERY arrival held before it, not just the last one. + * + *

Two devices can each reach the seam before the application enables continuity -- a + * synced-store listener installs one without enabling anything, and a key/value store is not + * consent to restore a route stack -- so the second displaces the first onto the shelf. A + * drain that took only the slot left that first arrival in a state nothing resolved: never + * dispatched, so its listeners and provider never ran, and reachable only if the application + * happened to call getRestorableState() by hand.

+ * + *

And worse than merely unreachable once the shelf started holding relay publication back, + * which the same change introduced: an arrival nothing was ever going to dispatch withheld + * every checkpoint this device made for the rest of the process.

+ */ + @EdtTest + public void enableDrainsEveryArrivalHeldBeforeItNotJustTheLast() { + // Staged BEFORE anything enables continuity, which is the whole point: these reach the + // seam while the application has said nothing, so the framework holds them itself. + ContinuityCallback c = Continuity.callbackForTest(); + long base = System.currentTimeMillis() - 5000L; + Map first = new HashMap(); + first.put("note", "from the phone"); + c.continuationReceived(Continuity.getActivityType(), StateCodec.toMap(new AppState() + .setPayload(first).setDeviceId("phone").setSequence(1L).setTimestamp(base))); + flushSerialCalls(); + Map second = new HashMap(); + second.put("note", "from the tablet"); + c.continuationReceived(Continuity.getActivityType(), StateCodec.toMap(new AppState() + .setPayload(second).setDeviceId("tablet").setSequence(1L) + .setTimestamp(base + 1000L))); + flushSerialCalls(); + + final List seen = new ArrayList(); + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen.add(state.getDeviceId()); + return true; + } + }); + Continuity.setStateProvider(new RecordingProvider()); + Continuity.enable(); + flushSerialCalls(); + flushSerialCalls(); + flushSerialCalls(); + + assertTrue(seen.contains("tablet"), + "the arrival in the slot was never dispatched by the enable: " + seen); + assertTrue(seen.contains("phone"), + "the arrival displaced onto the shelf before enable() was never dispatched, so " + + "its listeners and provider never saw it and it went on holding every " + + "relay checkpoint behind it: " + seen); + } + + /** + * The documented logout flow closes the gap in front of the login screen. + * + *

clear() alone is not a logout and deliberately does not try to be: it forgets the + * account's data and leaves continuity switched ON, because forgetting state and turning the + * feature off are two different things and an app is entitled to do the first without the + * second. The consequence is that a continuation arriving while the login screen is up reaches + * a framework that is still enabled, and is valid 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 get restored over the login screen and written to storage.

+ * + *

disable() is what closes it. This pins that the pairing the guide and the clear() + * javadoc now prescribe is the thing that actually works, rather than advice nothing checks: + * take the disable() away and the arrival is restored.

+ */ + @EdtTest + public void theDocumentedLogoutFlowClosesTheGapBeforeTheLoginScreen() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + Continuity.setAutoRestore(true); + Continuity.enable(); + flushSerialCalls(); + + // The logout path, as the guide and the javadoc prescribe it. + Continuity.clear(); + Continuity.disable(); + flushSerialCalls(); + provider.restored = null; + + // A continuation lands while the login screen is up. It is not from the ended session -- + // it arrived after the clear -- so nothing else in this class refuses it. + Map payload = new HashMap(); + payload.put("note", "the signed-out account's work"); + ContinuityCallback c = Continuity.callbackForTest(); + assertTrue(c.continuationReceived(Continuity.getActivityType(), + StateCodec.toMap(new AppState() + .setPayload(payload).setDeviceId("phone").setSequence(7L) + .setTimestamp(System.currentTimeMillis()))), + "the arrival was not claimed, so the port goes on holding work for an account " + + "that has signed out"); + flushSerialCalls(); + flushSerialCalls(); + + assertNull(provider.restored, + "an arrival that landed in front of the login screen was restored, so the " + + "signed-out account's work is on the next user's screen"); + AppState left = Continuity.getRestorableState(); + assertFalse(left != null && "phone".equals(left.getDeviceId()), + "the arrival is still on offer, so the enable() at login will apply it"); + } + + static class RecordingProvider implements StateProvider { + final Map saved = new HashMap(); + Map restored; + + public Map saveState() { + return saved; + } + + public void restoreState(Map payload) { + restored = payload; + } + } + + static class CountingProvider implements StateProvider { + int saves; + + public Map saveState() { + saves++; + return null; + } + + public void restoreState(Map payload) { + } + } + + static class RecordingListener implements ContinuityListener { + AppState seen; + int calls; + + public boolean stateReceived(AppState state) { + calls++; + seen = state; + return true; + } + } + + static class CountingStoreListener implements SyncedStoreListener { + int calls; + + public void storeChanged() { + calls++; + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java new file mode 100644 index 00000000000..903066703f9 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/RouteStackRestoreTest.java @@ -0,0 +1,342 @@ +/* + * 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.impl.continuity.LocalContinuityBridge; +import com.codename1.io.Storage; +import com.codename1.junit.FormTest; +import com.codename1.router.Navigation; +import com.codename1.router.RouteDispatcher; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +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.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.assertTrue; + +/** + * Where the route table and state restoration meet. + * + *

{@link Navigation#restoreStack} is the whole reason an app whose screens carry {@code @Route} + * gets them back with no code: the saved state is a list of paths, and each one has to become a + * stack frame again or {@link Navigation#back()} would land on a screen that was never rebuilt.

+ * + *

Everything the route half needs is public API -- {@link Navigation#setDispatcher} takes the + * generated table, and a test double stands in for it -- so this lives beside the other continuity + * tests rather than in {@code com.codename1.router}, and reaches the framework's package-private + * test seams from there.

+ */ +class RouteStackRestoreTest extends UITestBase { + + /** Returns a fresh titled Form for a registered path, null for anything else. */ + private static final class FakeDispatcher implements RouteDispatcher { + final Map known = new HashMap(); + final List dispatched = new ArrayList(); + + FakeDispatcher route(String path) { + known.put(path, Boolean.TRUE); + return this; + } + + public Form dispatch(String url) { + dispatched.add(url); + if (known.containsKey(url)) { + Form f = new Form(); + f.setTitle(url); + return f; + } + return null; + } + } + + @BeforeEach + void resetFramework() { + Continuity.reset(); + Storage.getInstance().clearStorage(); + // The delivery high-water marks are DURABLE now, so they outlive reset() by design -- + // 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. + com.codename1.io.Preferences.delete(Continuity.PREF_SEEN); + Continuity.setBridge(new LocalContinuityBridge()); + Navigation.setDispatcher(null); + new Form("start").show(); + } + + @AfterEach + void clearFramework() { + Continuity.reset(); + Navigation.setDispatcher(null); + Storage.getInstance().clearStorage(); + } + + @FormTest + void restoringRebuildsEveryFrameAndShowsOnlyTheLast() { + FakeDispatcher dispatcher = new FakeDispatcher().route("/home").route("/users") + .route("/users/42"); + Navigation.setDispatcher(dispatcher); + + assertTrue(Navigation.restoreStack(Arrays.asList("/home", "/users", "/users/42"))); + + assertEquals(3, Navigation.getStack().size()); + assertEquals("/users/42", Navigation.getCurrent().getPath()); + assertEquals("/users/42", Display.getInstance().getCurrent().getTitle()); + // Every frame was built, which is what makes going back land on a real screen rather + // than on nothing. + assertEquals(Arrays.asList("/home", "/users", "/users/42"), dispatcher.dispatched); + } + + /** + * `lastSeen` is process-local, so a restart emptied it and the next poll -- automatic on an + * Android resume -- accepted the same (device, sequence) again and restored a foreign state a + * second time, prompting the user on every launch. The stored checkpoint carries the id and + * sequence of whatever was last acted on, so enable() seeds the high-water mark from it. + */ + @FormTest + void aRestoredForeignStateIsNotActedOnAgainAfterARestart() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/cart")); + Continuity.enable(); + + AppState remote = new AppState(); + remote.setRoutes(Arrays.asList("/home", "/cart")) + .setDeviceId("a-different-device") + .setSequence(7) + .setTimestamp(System.currentTimeMillis()); + assertTrue(Continuity.restore(remote), "the stack was supposed to be rebuilt"); + + // The restart: everything process-local goes, storage stays -- which is exactly what a + // relaunch looks like. + Continuity.reset(); + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/cart")); + Continuity.setBridge(new LocalContinuityBridge()); + Continuity.enable(); + + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + Continuity.deliver(remote); + // Drained, not merely queued. deliver() dispatches through callSerially and this test body + // IS the EDT, so asserting straight away asserted nothing: the count was zero whether the + // state had been dropped or was still sitting in the queue -- which is exactly how the + // first version of this test passed with the fix reverted. invokeAndBlock releases the EDT + // to run what is queued while this waits. + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + try { + Thread.sleep(300); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + + assertEquals(0, seen[0], + "the state acted on before the restart was delivered again, so the user is " + + "prompted on every launch"); + } + + /** + * Applying an inbound stack is not the user navigating, and the difference is not cosmetic. + * A checkpoint here republishes the state we just received under THIS device's id and a fresh + * sequence, so the device that sent it can no longer recognize its own work -- it arrives as + * a foreign device's state, gets restored, gets published back, and the two devices bounce + * the same stack between them, re-navigating the user on every poll. + */ + @FormTest + void applyingAnInboundStackDoesNotQueueACheckpoint() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/cart")); + Continuity.enable(); + + AppState remote = new AppState(); + remote.setRoutes(Arrays.asList("/home", "/cart")) + .setDeviceId("a-different-device") + .setSequence(9) + .setTimestamp(System.currentTimeMillis()); + + assertTrue(Continuity.restore(remote), "the stack was supposed to be rebuilt"); + + assertFalse(Continuity.isCheckpointPending(), + "restoring queued a checkpoint, so the state would go back out as ours"); + } + + /** + * The other half of the same rule: the suppression lasts exactly as long as the restore. Real + * navigation afterwards is the user moving and has to be published, or a device that received + * a state once would go quiet for the rest of the session. + */ + @FormTest + void navigatingAfterARestoreCheckpointsAgain() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/cart")); + Continuity.enable(); + + AppState remote = new AppState(); + remote.setRoutes(Arrays.asList("/home", "/cart")) + .setDeviceId("a-different-device") + .setSequence(9) + .setTimestamp(System.currentTimeMillis()); + Continuity.restore(remote); + assertFalse(Continuity.isCheckpointPending()); + + assertTrue(Navigation.back(), "the rebuilt stack was supposed to have a frame to go back to"); + + assertTrue(Continuity.isCheckpointPending(), + "navigation after a restore stopped checkpointing, so the device went silent"); + } + + @FormTest + void goingBackAfterARestoreLandsOnTheRebuiltFrame() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/users/42")); + Navigation.restoreStack(Arrays.asList("/home", "/users/42")); + + assertTrue(Navigation.back()); + + assertEquals("/home", Navigation.getCurrent().getPath()); + assertEquals("/home", Display.getInstance().getCurrent().getTitle()); + } + + /** + * A screen goes away in a rebuild and the states already sitting on the user's other devices + * still name it. Losing the whole session over one frame the user was not even on would be a + * worse answer than restoring the rest. + */ + @FormTest + void aPathThisBuildNoLongerRoutesIsSkippedRatherThanFailingTheRestore() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/users/42")); + + assertTrue(Navigation.restoreStack( + Arrays.asList("/home", "/a-screen-that-was-removed", "/users/42"))); + + assertEquals(2, Navigation.getStack().size()); + assertEquals("/users/42", Navigation.getCurrent().getPath()); + } + + @FormTest + void aStackWhoseEveryPathIsGoneRestoresNothingAndSaysSo() { + Navigation.setDispatcher(new FakeDispatcher().route("/home")); + + assertFalse(Navigation.restoreStack(Arrays.asList("/gone", "/also-gone"))); + } + + @FormTest + void restoringWithNoDispatcherOrNoPathsIsAnInertFalse() { + assertFalse(Navigation.restoreStack(Arrays.asList("/home"))); + + Navigation.setDispatcher(new FakeDispatcher().route("/home")); + assertFalse(Navigation.restoreStack(null)); + assertFalse(Navigation.restoreStack(new ArrayList())); + } + + // ------------------------------------------------------------------ + // End to end: navigate, checkpoint, forget everything, restore + // ------------------------------------------------------------------ + + /** + * The whole feature in one test: the user walks through three screens, the process is + * replaced, and the app comes back where they were with the payload intact. + */ + @FormTest + void aNavigatedSessionSurvivesTheProcessBeingReplaced() { + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/users") + .route("/users/42")); + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("scrollY", Integer.valueOf(240)); + Continuity.setStateProvider(provider); + + // The navigation stack is process-global static, so the app is put ON /home by replacing + // the stack rather than by navigating to it -- a plain navigate would append to whatever + // an earlier test in this class left behind, and the assertion below would be reading + // that instead of this session. + Navigation.restoreStack(Arrays.asList("/home")); + Navigation.navigate("/users"); + Navigation.navigate("/users/42"); + flushSerialCalls(); + + // The process is replaced: the framework forgets everything it holds in memory, the + // stored checkpoint is all that is left, and the route table is reinstalled by the + // generated bootstrap exactly as it is at startup. + AppState onDisk = Continuity.getRestorableState(); + assertNotNull(onDisk); + assertEquals(Arrays.asList("/home", "/users", "/users/42"), onDisk.getRoutes()); + Continuity.reset(); + Navigation.restoreStack(new ArrayList()); + Navigation.setDispatcher(new FakeDispatcher().route("/home").route("/users") + .route("/users/42")); + RecordingProvider afterRestart = new RecordingProvider(); + Continuity.setBridge(new LocalContinuityBridge()); + Continuity.setStateProvider(afterRestart); + + assertTrue(Continuity.restore()); + + assertEquals(3, Navigation.getStack().size()); + assertEquals("/users/42", Navigation.getCurrent().getPath()); + assertEquals(Integer.valueOf(240), afterRestart.restored.get("scrollY")); + } + + /** + * An app that navigates with {@code new MyForm().show()} records no routes, so restoration is + * the payload alone -- and {@link Continuity#restore()} answers false, which is what lets + * "restore, or else begin" still show that app's first screen. + */ + @FormTest + void anAppWithNoRoutesRestoresThePayloadAndShowsNothing() { + RecordingProvider provider = new RecordingProvider(); + provider.saved.put("draft", "unsent"); + Continuity.setStateProvider(provider); + Continuity.checkpoint(); + + Continuity.reset(); + Continuity.setBridge(new LocalContinuityBridge()); + RecordingProvider afterRestart = new RecordingProvider(); + Continuity.setStateProvider(afterRestart); + + assertFalse(Continuity.restore()); + assertEquals("unsent", afterRestart.restored.get("draft")); + } + + static class RecordingProvider implements StateProvider { + final Map saved = new HashMap(); + Map restored; + + public Map saveState() { + return saved; + } + + public void restoreState(Map payload) { + restored = payload; + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java index 875b03d0663..2315abbc20d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/router/NavigationTest.java @@ -24,6 +24,7 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.Display; import com.codename1.ui.Form; import java.util.HashMap; @@ -72,6 +73,223 @@ private int baseline() { return Navigation.getStack().size(); } + /** A Form that refuses to be shown, the way application code reached from show() -- a + * showListener, or an overridden onShowCompleted -- can. Forward navigation and back + * navigation are separated because a stack cannot be built out of forms that refuse both. */ + private static final class RefusingForm extends Form { + private final boolean refuseForward; + + RefusingForm(String title, boolean refuseForward) { + setTitle(title); + this.refuseForward = refuseForward; + } + + public void show() { + if (refuseForward) { + throw new IllegalStateException("this form refuses to be shown"); + } + super.show(); + } + + public void showBack() { + if (!refuseForward) { + throw new IllegalStateException("this form refuses to be shown"); + } + super.showBack(); + } + } + + /** A dispatcher that answers one path with a form that refuses, and every other path with an + * ordinary one. */ + private static final class RefusingDispatcher implements RouteDispatcher { + private final String refusing; + private final boolean refuseForward; + + RefusingDispatcher(String refusing, boolean refuseForward) { + this.refusing = refusing; + this.refuseForward = refuseForward; + } + + public Form dispatch(String url) { + if (refusing.equals(url)) { + return new RefusingForm(url, refuseForward); + } + Form f = new Form(); + f.setTitle(url); + return f; + } + } + + /** + * A navigation whose show() throws leaves the stack as it was. + * + *

The stack-change notification moved BEFORE show() so a listener sees the entry it is + * about to record, which is what makes a checkpoint describe the screen the user is going + * to. The cost of that ordering is this case: show() runs application code and can throw + * before the form is ever installed, and the entry left behind was then a screen nobody ever + * saw -- persisted by the checkpoint already queued, and restored after a process death.

+ */ + @FormTest + void navigateRollsTheStackBackWhenShowThrows() { + Navigation.setDispatcher(new FakeDispatcher().route("/a")); + Navigation.navigate("/a"); + int before = baseline(); + NavigationEntry current = Navigation.getCurrent(); + + Navigation.setDispatcher(new RefusingDispatcher("/explodes", true)); + try { + Navigation.navigate("/explodes"); + fail("show() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller sees the failure. What must not survive it is the stack entry. + } + + assertEquals(before, baseline(), + "the entry for a screen that was never shown stayed on the stack, so a " + + "checkpoint persists it and a cold start restores a screen the user " + + "never reached"); + assertSame(current, Navigation.getCurrent(), + "the failed navigation is reported as the current entry"); + } + + /** + * A form that DID get shown keeps its stack entry, so the stack and the display agree. + * + *

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. Rolling + * the entry back regardless left Navigation.getCurrent() disagreeing with + * Display.getCurrent(): back() worked on a stack whose top was not the visible form, and a + * checkpoint persisted a screen the user was not on.

+ * + *

Not fixed by re-showing the previous form, which 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 which can throw in its turn.

+ */ + @FormTest + void aFormThatWasShownKeepsItsEntryWhenItsListenerThrows() { + Navigation.setDispatcher(new FakeDispatcher().route("/a")); + Navigation.navigate("/a"); + int before = baseline(); + + 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 form is already installed by the time this runs. + throw new IllegalStateException("the show listener failed"); + } + }); + return f; + } + }); + try { + Navigation.navigate("/shown-then-throws"); + fail("the show listener did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller still sees it. + } + + assertEquals(before + 1, baseline(), + "the entry for a form that IS on screen was rolled back, so the stack no longer " + + "describes what the user is looking at"); + assertSame(Display.getInstance().getCurrent(), Navigation.getCurrent().getForm(), + "Navigation.getCurrent() and Display.getCurrent() disagree, so back() works on a " + + "stack whose top is not the visible form and a checkpoint persists a " + + "screen the user is not on"); + } + + /** + * A stack the show callback CLEARED is not resurrected by the rollback. + * + *

The rollback exists so a screen the user never saw does not stay on the stack. But + * show() runs application code, and that code can navigate: the case that matters is a show + * listener discovering the session has expired, logging out -- which empties this stack on + * purpose -- and then throwing 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. The rollback meant to help undid the one thing the + * logout existed to do.

+ */ + @FormTest + void aStackClearedByTheShowCallbackIsNotResurrected() { + Navigation.setDispatcher(new FakeDispatcher().route("/a").route("/b")); + Navigation.navigate("/a"); + Navigation.navigate("/b"); + assertTrue(baseline() >= 2, "the fixture did not build a stack to lose"); + + Navigation.setDispatcher(new RouteDispatcher() { + public Form dispatch(String url) { + return new Form() { + public void show() { + // A logout discovered on screen, and then a failure on the way out. + Navigation.clearStack(); + throw new IllegalStateException("the session had expired"); + } + }; + } + }); + try { + Navigation.navigate("/whatever"); + fail("show() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // The caller sees it, as it must. + } + + assertEquals(0, baseline(), + "the rollback put the cleared stack back, so the signed-out account's forms are " + + "reachable through back() again and the next checkpoint persists them"); + } + + /** + * A back whose showBack() throws puts the popped entry back. + */ + @FormTest + void backRestoresThePoppedEntryWhenShowThrows() { + Navigation.setDispatcher(new RefusingDispatcher("/refuses", false)); + Navigation.navigate("/refuses"); + Navigation.navigate("/top"); + int before = baseline(); + NavigationEntry top = Navigation.getCurrent(); + + try { + Navigation.back(); + fail("showBack() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // As above. + } + + assertEquals(before, baseline(), + "the entry was popped for a screen that never appeared, so the stack now " + + "describes a place the user is not"); + assertSame(top, Navigation.getCurrent(), "the failed back moved the current entry"); + } + + /** + * A popTo whose showBack() throws puts every popped entry back. + */ + @FormTest + void popToRestoresEveryPoppedEntryWhenShowThrows() { + Navigation.setDispatcher(new RefusingDispatcher("/refuses", false)); + Navigation.navigate("/refuses"); + NavigationEntry target = Navigation.getCurrent(); + Navigation.navigate("/mid"); + Navigation.navigate("/top"); + int before = baseline(); + NavigationEntry top = Navigation.getCurrent(); + + try { + Navigation.popTo(target); + fail("showBack() did not throw, so this test is about nothing"); + } catch (IllegalStateException expected) { + // As above. + } + + assertEquals(before, baseline(), + "popTo dropped several entries for a screen that never appeared"); + assertSame(top, Navigation.getCurrent(), "the failed popTo moved the current entry"); + } + @FormTest void navigateReturnsFalseWithoutDispatcher() { Navigation.setDispatcher(null); diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java new file mode 100644 index 00000000000..8881dbc94e4 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/continuity/ContinuitySimulatorHooksTest.java @@ -0,0 +1,99 @@ +/* + * 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.continuity; + +import com.codename1.impl.continuity.LocalContinuityBridge; +import com.codename1.impl.javase.ContinuitySimulatorHooks; +import com.codename1.impl.javase.JavaSEPort; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The "synced store unsupported" simulation has to disable the STORE, not only its answer about + * itself. + * + *

The framework deliberately stopped gating store calls on {@code isSyncedStoreSupported()}: on + * iOS the store is local and works whether or not the build is entitled to sync it. That left this + * hook overriding the predicate alone and nothing else, so the simulation kept a fully working + * store -- and an application that ignores {@code isSupported()} kept its setting here while + * losing it on Android, which is the exact failure the menu item exists to reproduce.

+ */ +class ContinuitySimulatorHooksTest { + + @AfterEach + void restoreTheCapablePlatform() { + ContinuitySimulatorHooks.makeEverythingSupported(); + } + + @Test + void theUnsupportedStoreSimulationAlsoDisablesTheOperations() throws Exception { + // Asserted on what the installed bridge OVERRIDES rather than on values it stores. The + // store persists through Storage, which this harness has no initialised runtime for, so a + // value-level check could not run -- and worse, "put returned false" there would be + // ambiguous between "this platform has no store" and "there was nowhere to write", which + // is precisely the distinction the test exists to make. + // The control is the SIBLING hook, not the plain bridge. makeEverythingSupported() + // installs LocalContinuityBridge itself, which of course declares the store methods -- + // it is the implementation -- so comparing against it proved nothing and said so when it + // fired. makeContinuationUnsupported() installs the same SHAPE, an anonymous subclass + // overriding one predicate, and it must NOT touch the store. + ContinuitySimulatorHooks.makeContinuationUnsupported(); + Class otherHook = JavaSEPort.getSimulatedContinuity().getClass(); + + ContinuitySimulatorHooks.makeTheSyncedStoreUnsupported(); + LocalContinuityBridge unsupported = JavaSEPort.getSimulatedContinuity(); + + assertFalse(unsupported.isSyncedStoreSupported(), + "the hook did not make the store report itself unsupported"); + + Class off = unsupported.getClass(); + String[] operations = {"syncedStorePut", "syncedStoreGet", "syncedStoreRemove", + "syncedStoreKeys"}; + Class[][] signatures = { + {String.class, String.class}, {String.class}, {String.class}, {} + }; + for (int i = 0; i < operations.length; i++) { + assertTrue(declares(off, operations[i], signatures[i]), + operations[i] + " is inherited from the working store, so a platform with no " + + "synced store still keeps and returns values -- an application that " + + "ignores isSupported() passes here and loses its setting on Android"); + // The control: a hook about a DIFFERENT capability must not override them, or the + // check above would be satisfied by any anonymous bridge at all. + assertFalse(declares(otherHook, operations[i], signatures[i]), + "the continuation hook overrides " + operations[i] + " as well, so the " + + "assertion above is true of any hook and distinguishes nothing"); + } + } + + private static boolean declares(Class c, String name, Class[] args) { + try { + c.getDeclaredMethod(name, args); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/simulator/ShippedSimulatorHooksTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/ShippedSimulatorHooksTest.java new file mode 100644 index 00000000000..a15c29e15fa --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/simulator/ShippedSimulatorHooksTest.java @@ -0,0 +1,179 @@ +/* + * 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.simulator; + +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +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.assertTrue; + +/** + * The simulator hooks this port actually ships, rather than a fixture. + * + *

{@link SimulatorHookLoader} is deliberately forgiving: a group naming a class that cannot be + * loaded, or a method that is not {@code public static void}, is skipped and the scan continues. + * That is the right behaviour for a cn1lib whose classes may legitimately be absent, and it means + * a typo in this port's own file costs a whole Simulate menu with nothing said anywhere -- which + * is exactly the sort of failure nobody notices until someone reaches for the menu and it is not + * there.

+ * + *

So this walks {@code META-INF/codenameone/simulator-hooks.properties} as written and insists + * every group listed is declared, every declared item resolves, and the numbering has no hole in + * it. Sibling coverage to {@link SimulatorHookLoaderTest}, which tests the parser against files it + * writes itself.

+ */ +class ShippedSimulatorHooksTest { + + private static final String RESOURCE = "META-INF/codenameone/simulator-hooks.properties"; + + private static Properties shipped() throws Exception { + InputStream in = ShippedSimulatorHooksTest.class.getClassLoader() + .getResourceAsStream(RESOURCE); + assertNotNull(in, RESOURCE + " is not on the test classpath"); + try { + Properties props = new Properties(); + props.load(in); + return props; + } finally { + in.close(); + } + } + + private static List groups(Properties props) { + List out = new ArrayList(); + String declared = props.getProperty("groups"); + assertNotNull(declared, "the shipped file declares no groups"); + for (String group : declared.split(",")) { + String trimmed = group.trim(); + if (trimmed.length() > 0) { + out.add(trimmed); + } + } + return out; + } + + @Test + void everyDeclaredGroupHasANameAndAtLeastOneItem() throws Exception { + Properties props = shipped(); + List groups = groups(props); + assertFalse(groups.isEmpty(), "no groups declared"); + for (String group : groups) { + assertNotNull(props.getProperty(group + ".name"), group + " has no name"); + assertNotNull(props.getProperty(group + ".item1"), + group + " declares no items, so it would surface as an empty menu"); + } + } + + /** + * The loader stops reading a group at its first missing index, so a hole silently truncates + * the menu: an item9 written after item7 with no item8 is simply never registered. + */ + @Test + void itemNumberingHasNoHoles() throws Exception { + Properties props = shipped(); + for (String group : groups(props)) { + int highest = 0; + for (Object key : props.keySet()) { + String name = (String) key; + String prefix = group + ".item"; + if (name.startsWith(prefix)) { + int n = Integer.parseInt(name.substring(prefix.length())); + if (n > highest) { + highest = n; + } + } + } + for (int i = 1; i <= highest; i++) { + assertNotNull(props.getProperty(group + ".item" + i), + group + ".item" + i + " is missing, so every item after it is dropped"); + } + } + } + + /** + * Every action resolves to a {@code public static void} method that actually exists. A + * misspelling here is not an error at load time -- the entry is skipped -- so nothing tells + * anyone until the menu item is missing. + */ + @Test + void everyDeclaredActionResolves() throws Exception { + Properties props = shipped(); + int checked = 0; + for (String group : groups(props)) { + for (int i = 1; ; i++) { + String action = props.getProperty(group + ".item" + i); + if (action == null) { + break; + } + int hash = action.indexOf('#'); + assertTrue(hash > 0, action + " is not #"); + String className = action.substring(0, hash); + String methodName = action.substring(hash + 1); + Class cls = Class.forName(className); + Method m = cls.getDeclaredMethod(methodName); + assertTrue(java.lang.reflect.Modifier.isStatic(m.getModifiers()), + action + " is not static"); + assertTrue(java.lang.reflect.Modifier.isPublic(m.getModifiers()), + action + " is not public"); + assertEquals(void.class, m.getReturnType(), action + " does not return void"); + checked++; + } + } + assertTrue(checked > 0, "no actions were checked, so this test proved nothing"); + } + + /** Two groups sharing a namespace would make CN.execute ambiguous. */ + @Test + void namespacesAreUnique() throws Exception { + Properties props = shipped(); + Set seen = new HashSet(); + for (String group : groups(props)) { + String namespace = props.getProperty(group + ".namespace"); + if (namespace == null) { + namespace = SimulatorHookLoader.slugify(props.getProperty(group + ".name")); + } + assertTrue(seen.add(namespace), "two groups share the namespace " + namespace); + } + } + + /** The group added for state restoration and continuity is present and wired. */ + @Test + void continuityHooksAreRegistered() throws Exception { + Properties props = shipped(); + assertTrue(groups(props).contains("continuity"), + "the continuity group is not in the groups list, so none of it loads"); + assertEquals("continuity", props.getProperty("continuity.namespace")); + assertEquals("com.codename1.impl.javase.ContinuitySimulatorHooks#continueHere", + props.getProperty("continuity.item1")); + } +} diff --git a/scripts/developer-guide/missing-code-blocks-baseline.txt b/scripts/developer-guide/missing-code-blocks-baseline.txt index 1ab76343203..c0bed1c9798 100644 --- a/scripts/developer-guide/missing-code-blocks-baseline.txt +++ b/scripts/developer-guide/missing-code-blocks-baseline.txt @@ -181,59 +181,6 @@ appendix_goal_generate_graphql.adoc ends the stream: appendix_goal_generate_grpc.adoc Call sites use the static factory: appendix_goal_generate_grpc.adoc The `@GrpcClient` interface looks like: appendix_goal_generate_openapi.adoc Call sites use the static factory: -io.asciidoc A more advanced usage of the `FileSystemStorage` API can be a `FileSystemStorage` `Tree`: -io.asciidoc A simpler implementation could do something like this: -io.asciidoc Above, if you want to select the IDs of all players that are ranked in the top 2, you can use an expression like: -io.asciidoc Above, you globally find a lastname element with a value of ‘Hewitt’, then grab the parent node of lastname which happens to be the player node, then grab the ID attribute from the player node. Or, you could get the same result from the following simpler statement: -io.asciidoc Above, you selected the IDs of all ranked players. Conversely, you can select the non-ranked players like this: -io.asciidoc After you do that once you can write/read contacts from storage if you so want: -io.asciidoc An `Externalizable` object *must* have a *default public constructor* and must implement the following 4 methods: -io.asciidoc And delete an entry using: -io.asciidoc And vice versa: -io.asciidoc Another approach is to use the `setFailSilently(true)` method on the `ConnectionRequest`. This will prevent the `ConnectionRequest` from displaying any errors to the user. It's a powerful strategy if you use the synchronous version of the APIs for example: -io.asciidoc As part of the premium cloud features it's possible to invoke Log.sendLog() to email a log directly to the developer account. Codename One can do that seamlessly based on changes printed into the log or based on exceptions that are uncaught or logged for example: -io.asciidoc Assuming you added a new date field to the object you can do the following. Notice that a `Date` is a `long` value in Java that can be null. For completeness the full class is presented below: -io.asciidoc Binding makes this all seamless. For example: the code above can be written as: -io.asciidoc By default `GZConnectionRequest` doesn't request gzipped data ( unzips it when its received) but its pretty easy to do so add the HTTP header `Accept-Encoding: gzip` for example: -io.asciidoc Codename One provides many tools to simplify the path between networking/IO & GUI. A common task of showing a wait dialog or progress sign while fetching network data can be simplified by using the https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html[InfiniteProgress] class for example: -io.asciidoc Developers need to write the data of the object in the externalize method using the methods in the data output stream and read the data of the object in the internalize method for example: -io.asciidoc For a lot of REST requests this will fail because you need to add an HTTP header indicating that you accept JSON results. You have a special case support for that: -io.asciidoc For endpoints that return a list of DTOs, use `fetchAsMappedList`: -io.asciidoc For example, if you wish to have finer grained control over the submission process for example: for making a `HEAD` request you can do this with code like: -io.asciidoc For example: to block all network errors from showing anything to the user you could do something like this: -io.asciidoc For example: you can do something like this in your `init(Object)` method: -io.asciidoc For starters all the common methods of `Object` can be implemented with almost no code: -io.asciidoc If a document is ordered, you might want to select nodes by their position, for example: -io.asciidoc If you continue the example from above to show persistence to the SQL database you can do something like this: -io.asciidoc If you continue your example from above you can do something like this: -io.asciidoc Implementing the `Externalizable` interface is important when you want to store a proprietary object. In this case you must register the object with the `com.codename1.io.Util` class so the externalization algorithm will be able to recognize it by name by invoking: -graphics.asciidoc (that's: where the numbers appear), and the remaining marks (corresponding with seconds) will be short: -graphics.asciidoc 3. Invert the translation performed in step 1: -graphics.asciidoc A `URLImage` can be created with a mask adapter to apply an effect to an image. This allows you to round downloaded images or apply any sort of masking for example: you can adapt the round mask code above as such: -graphics.asciidoc And you will translate it down slightly so that it overlaps the center. This translation will be performed on the `GeneralPath` object directly rather than through the `Graphics` context: -graphics.asciidoc Center: -graphics.asciidoc For example: a `pointerPressed()` callback method can look like this: -graphics.asciidoc The `animate()` method in the `AnalogClock` class: -graphics.asciidoc The code to instantiate the clock, and start the animation would be something like: -graphics.asciidoc The remaining drawing code is as follows: -graphics.asciidoc The simple use case is pretty trivial: -graphics.asciidoc To do this you can override: -graphics.asciidoc Top Left Corner: -graphics.asciidoc `ConicGradient` - following the same pattern as the `Shape` hierarchy: -graphics.asciidoc can override or augment whatever the default set: -graphics.asciidoc class is the most common option and accepts a list of color stops along a line: -graphics.asciidoc coordinates of the component. You therefore need to get the absolute clock center position to perform the rotation: -graphics.asciidoc for the images. The default is a scale adapter although you might change that to scale fill in the future: -graphics.asciidoc hook. Two ways to install one: -graphics.asciidoc https://www.codenameone.com/javadoc/com/codename1/ui/Graphics.html#isShapeSupported()[`Graphics.isShapeSupported()`] method. For example: -graphics.asciidoc it can be added to a form: -graphics.asciidoc mark at the 12 o'clock position: -graphics.asciidoc method to append curves to the drawing as follows: -graphics.asciidoc methods in the component as follows: -graphics.asciidoc name of icon to `icon_URLImage` then using this in the data: -graphics.asciidoc show this, try to place five of these components on a form inside a https://www.codenameone.com/javadoc/com/codename1/ui/layouts/BorderLayout.html[BorderLayout] and see how it looks: -graphics.asciidoc straight lines rather than curves might look like this: -graphics.asciidoc to draw each individual tick: io.asciidoc In the above code you do the following: io.asciidoc Since you assume most developers reading this will be familiar with Java here is the way to implement the multipart upload in the servlet API: io.asciidoc That's what the new method of `URLImage` does: diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index effff97ceb6..ccae138a75a 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -535,6 +535,12 @@ private static int testTimeoutMs(BaseTest testClass) { // at all is also what makes the iOS extension target and the Android get // generated and compiled in the first place. new DocumentProviderPublishTest(), + // State restoration and continuity on the device VM: the codec both wire formats + // share, the payload rule, the checkpoint and the restore. Referencing + // com.codename1.continuity at all is also what makes the iOS build compile the + // NSUserActivity natives and declare this app's activity type in the plist. + new ContinuityStateTest(), + // App intents on the device VM: the generated registry, the coercion it wraps // every parameter in, and entity resolution behind an id. The declarations it // exercises are also what make the iOS Swift and the Android shortcut resources diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java new file mode 100644 index 00000000000..0bbbd8bbd4d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (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.examples.hellocodenameone.tests; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.StateCodec; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Saves and restores application state on the device VM, so CI runs what the build generates. +/// +/// Declaring this is part of the coverage. Without a reference to `com.codename1.continuity` +/// anywhere in the project the iOS builder leaves `CN1_USE_CONTINUITY` commented out, so the +/// `NSUserActivity` natives and the continuity branch in the app delegate are never compiled, +/// and this app's activity type never reaches `NSUserActivityTypes` for the plist to be checked. +/// Every mistake in that half -- an Apple API misused, a plist key Xcode will not take, a native +/// symbol whose mangled name does not match the Java declaration -- is invisible until something +/// references the package. +/// +/// The rest is the half that has no platform behind it and therefore has to behave identically +/// everywhere: the codec both wire formats share, the payload rule that makes them possible, the +/// checkpoint, and the dedup that stops one state being acted on twice. Assertion-only test, no +/// screenshot. +public class ContinuityStateTest extends BaseTest { + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + /// Progress marker, printed before each phase. + /// + /// This test wedged the tvOS suite: the console stops immediately after the support-probe + /// line and every test after it alphabetically never runs, so the run reports them as + /// "missing actual" and the suite never emits SUITE:FINISHED. It has done that since the + /// test was added, and it does NOT happen on iOS, where the same test runs to completion. + /// + /// The markers are how the next tvOS run says which statement it stops on, and they earn + /// their place afterwards: this is a device conformance test whose whole job is to report + /// what a platform does. + private static void phase(String name) { + System.out.println("CN1SS:INFO:test=ContinuityStateTest phase=" + name); + } + + @Override + public boolean runTest() { + try { + // Support probes must never throw, whatever they answer. + boolean continuation = Continuity.isContinuationSupported(); + boolean synced = SyncedStore.isSupported(); + System.out.println("CN1SS:INFO:test=ContinuityStateTest continuation=" + continuation + + " syncedStore=" + synced + + " platform=" + Display.getInstance().getPlatformName()); + + // The activity type is derived from the package name on this side and written into + // NSUserActivityTypes by the build on the other. If the two ever disagree, iOS + // silently refuses to deliver anything -- so the shape is asserted where it is + // computed. + phase("activityType"); + String activityType = Continuity.getActivityType(); + assertBool(activityType != null && activityType.endsWith(".continuity"), + "activity type ends with .continuity"); + + phase("provider"); + final Map restored = new HashMap(); + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", "cn1ss draft"); + state.put("count", Integer.valueOf(3)); + return state; + } + + public void restoreState(Map payload) { + restored.putAll(payload); + } + }); + assertBool(Continuity.isEnabled(), "installing a provider enables the framework"); + + phase("title"); + Continuity.setTitle("cn1ss continuity"); + phase("checkpoint"); + Continuity.checkpoint(); + + phase("restorable"); + AppState stored = Continuity.getRestorableState(); + assertBool(stored != null, "a checkpoint leaves a restorable state"); + assertEqual("cn1ss draft", stored.getPayload().get("draft"), "stored payload"); + assertBool(stored.getSequence() > 0, "a stored state carries a sequence"); + assertBool(stored.getDeviceId() != null && stored.getDeviceId().length() > 0, + "a stored state names the device that produced it"); + + // Restoring an app with no routes hands the payload back and shows nothing, which is + // what lets "restore, or else begin" work. Answering true here would make such an app + // skip its own first screen. + phase("restore"); + assertBool(!Continuity.restore(), "a routeless restore shows no form"); + assertEqual("cn1ss draft", restored.get("draft"), "the payload reached the provider"); + + // Both wire formats, on the device VM. The JSON one crosses the network to another + // device and the map one is handed to the operating system, and a millisecond + // timestamp is past the range a JSON number represents exactly -- which is why they + // are encoded as strings and why that is asserted rather than assumed. + phase("wire"); + AppState wire = new AppState() + .setRoutes(routes()) + .setPayload(payload()) + .setDeviceId("cn1ss-device") + .setSequence(4242L) + .setTimestamp(1763512345678L); + AppState viaJson = StateCodec.fromJson(StateCodec.toJson(wire)); + assertBool(viaJson != null, "a state survives the JSON form"); + assertEqual(1763512345678L, viaJson.getTimestamp(), "timestamp survives JSON exactly"); + assertEqual(4242L, viaJson.getSequence(), "sequence survives JSON exactly"); + assertEqual(2, viaJson.getRoutes().size(), "routes survive JSON"); + AppState viaMap = StateCodec.fromMap(StateCodec.toMap(wire)); + assertBool(viaMap != null, "a state survives the map form"); + assertEqual("cn1ss", viaMap.getPayload().get("name"), "payload survives the map form"); + + // The payload rule is enforced where the application can act on it, on every port. + phase("payloadRule"); + boolean refused = false; + try { + Map bad = new HashMap(); + bad.put("when", new java.util.Date()); + new AppState().setPayload(bad); + } catch (IllegalArgumentException expected) { + refused = true; + } + assertBool(refused, "an unrepresentable payload value is refused"); + + // The synced store answers honestly on the ports that have none, and every call is + // safe there. This is the ordinary case for Android, the desktop and the browser. + // + // A key of THIS RUN's own, removed first and cleaned up in a finally. A fixed key left + // behind by a run that was interrupted between the write and the removal made the + // absent-value assertion fail on every later run, permanently -- and on iOS the value + // can also arrive from another device, which no amount of local cleanup prevents. + phase("syncedStore"); + String key = "cn1ss.sortOrder." + System.currentTimeMillis(); + try { + SyncedStore.remove(key); + assertEqual("byName", SyncedStore.get(key, "byName"), + "an absent synced value answers with the default"); + + // NOT asserted equal to isSupported(). They answer different questions on iOS by + // design: isSupported() reports whether the entitlement probe has established a + // store that follows the user, while put() writes to the local persistent store + // and succeeds even when that probe has not -- and a store at its quota refuses a + // write while remaining perfectly supported. Tying them together made this fail + // for a device that was merely offline, or whose store was full, with both APIs + // keeping their documented contracts. + boolean wrote = SyncedStore.put(key, "byDate"); + if (wrote) { + assertEqual("byDate", SyncedStore.get(key, "byName"), + "a value the store accepted reads back"); + } else { + assertEqual("byName", SyncedStore.get(key, "byName"), + "a write the store refused left nothing behind"); + } + assertBool(SyncedStore.keys() != null, "the key list is never null"); + } finally { + SyncedStore.remove(key); + } + + // Clearing must be safe everywhere, including twice and including when the platform + // never advertised anything. + Continuity.clear(); + Continuity.clear(); + assertBool(Continuity.getRestorableState() == null, + "clearing forgets the stored state"); + + // The device runner waits for this before moving on. A test that returns true + // without it never reports DONE, and the suite fails the whole port with + // "timeout waiting for DONE stage=created" rather than naming the test. + done(); + return true; + } catch (Throwable t) { + t.printStackTrace(); + done(); + return false; + } + } + + private static List routes() { + List paths = new ArrayList(); + paths.add("/home"); + paths.add("/users/42"); + return paths; + } + + private static Map payload() { + Map nested = new HashMap(); + nested.put("street", "Sesame"); + List tags = new ArrayList(); + tags.add("a"); + tags.add(Integer.valueOf(2)); + tags.add(Boolean.TRUE); + Map payload = new HashMap(); + payload.put("name", "cn1ss"); + payload.put("address", nested); + payload.put("tags", tags); + return payload; + } +} diff --git a/scripts/initializr/common/src/main/resources/skill/references/build-hints.md b/scripts/initializr/common/src/main/resources/skill/references/build-hints.md index 22e5b8b24f4..bee3847b865 100644 --- a/scripts/initializr/common/src/main/resources/skill/references/build-hints.md +++ b/scripts/initializr/common/src/main/resources/skill/references/build-hints.md @@ -120,6 +120,31 @@ If all you want is the app's own documents folder visible in Files, you need non The extension needs its own App ID and provisioning profile; `mvn cn1:certificatewizard` creates both, along with the App Group. +## State restoration and continuity + +Saves what the user was doing and brings it back after the OS kills the process, and -- on Apple platforms -- offers the same work to the other devices that person is signed in to. Referencing `com.codename1.continuity` is what makes an iOS build compile the `NSUserActivity` handling and declare the app's activity type in `NSUserActivityTypes`. Android needs nothing injected: no permission, no manifest entry, no dependency. + +Install a `StateProvider` in `init()` and let `start()` read as "restore, or else begin": + +```java +Continuity.setStateProvider(provider); // enables the framework +... +public void start() { + if (!Continuity.restore()) { + Navigation.navigate("/home"); + } +} +``` + +The framework already knows the `@Route` navigation stack and restores it with no code; the provider supplies everything else as a `Map`. Saving is continuous -- every navigation schedules a checkpoint -- so there is no "save on exit" hook to write. Call `Continuity.checkpoint()` after a change no navigation followed. + +| Hint (`codename1.arg.` prefix) | Effect | +| --- | --- | +| `ios.continuity.sync=false` | Skip the iCloud key-value store entitlement a reference to `com.codename1.continuity.sync` earns, leaving `SyncedStore` unsupported at runtime. | +| `ios.continuity.sync=true` | Declare the store explicitly, which is what lets the signing preflight check the provisioning profile before the build is sent. Left unset, the build decides from the bytecode. | + +Three things to get right. A payload admits only `String`, `Integer`, `Long`, `Double`, `Boolean` and `List`/`Map` of those, because it has to survive reaching another device -- anything else is refused where you produced it. `com.codename1.continuity.sync` is a separate package because it is the only half that costs an entitlement, which must be granted on the App ID or the build fails at codesigning. And Codename One runs no relay server: carrying state to a non-Apple device means implementing `StateRelay` (or subclassing `RestStateRelay`) against your own endpoint, because deciding which states belong to the same person is your account system's job. + ## JavaScript / web | Hint | Effect | diff --git a/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance b/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance new file mode 100644 index 00000000000..15da581f5ed --- /dev/null +++ b/scripts/javascript/screenshots/VideoIODecodedFrames.tolerance @@ -0,0 +1,13 @@ +# The test encodes six frames and decodes them back, so it compares whatever +# the platform codec returns -- lossy, and not bit-reproducible. Every other +# comparing port ships this same sidecar at these values; JavaScript was the +# one baseline without it, so readTolerance fell back to the harness defaults +# (delta 4 / 0.30%) and ordinary decode noise failed the build. +# +# Measured on the failure that prompted this: worst single-channel delta +# anywhere was 20/255, 2.611% of pixels exceeded delta 4, and 0.000% exceeded +# delta 72. The regressions this test exists to catch -- frames blank, +# reordered, wrong colour -- move whole blocks across most of the channel +# range, so they stay caught. +maxChannelDelta=72 +maxMismatchPercent=18.0