diff --git a/CodenameOne/src/com/codename1/continuity/AppState.java b/CodenameOne/src/com/codename1/continuity/AppState.java new file mode 100644 index 00000000000..d335d01a668 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/AppState.java @@ -0,0 +1,379 @@ +/* + * 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(); + 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 application payload. Never null, possibly empty. + /// + /// #### Returns + /// + /// an unmodifiable view of the payload + public Map getPayload() { + return Collections.unmodifiableMap(payload); + } + + /// Replaces the application payload. + /// + /// #### Parameters + /// + /// - `p`: the payload; null is treated as empty + /// + /// #### Returns + /// + /// this state, for chaining + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when a value cannot cross to another device + public AppState setPayload(Map p) { + StateCodec.requireRepresentable(p); + payload = 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); + } + + /// 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..c46ec8db2ad --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/Continuity.java @@ -0,0 +1,2166 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.Log; +import com.codename1.io.Preferences; +import com.codename1.io.Storage; +import com.codename1.io.Util; +import com.codename1.router.Navigation; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Saves what the user was doing, brings it back when the app starts again, and -- where the +/// platform or your own endpoint can carry it -- lets them pick it up on another device. +/// +/// ```java +/// // in init() +/// Continuity.setStateProvider(new StateProvider() { +/// public Map saveState() { +/// Map m = new HashMap(); +/// m.put("draft", draftField.getText()); +/// return m; +/// } +/// public void restoreState(Map payload) { +/// pendingDraft = (String) payload.get("draft"); +/// } +/// }); +/// +/// // in start() +/// if (!Continuity.restore()) { +/// Navigation.navigate("/home"); +/// } +/// ``` +/// +/// #### What is saved +/// +/// Two halves. The framework contributes the `com.codename1.router.Navigation` stack, so an app +/// whose screens are declared with `@Route` gets them back with no code at all. Your +/// `StateProvider` contributes everything else. An app that navigates with `new MyForm().show()` +/// has no route stack to save -- those navigations are not addressable -- and restores from the +/// payload alone. +/// +/// #### When it is saved +/// +/// Continuously, not at shutdown. Every navigation marks the state dirty and a checkpoint is +/// written once per event loop pass, so by the time the operating system suspends the app the work +/// is already done. This is deliberate: on Android the platform blocks its own main thread until +/// the app's `stop()` returns, and an app that did its saving there would be paying for it on +/// every single suspend. Call `checkpoint()` directly after changing something the provider +/// reports but no navigation touched. +/// +/// #### Getting it back +/// +/// `restore()` returns true when it showed something, so `start()` reads as "restore, or else +/// begin". It is never called for you: an app that adopts this API decides where restoration fits +/// in its own launch, and an app that does not is completely unaffected. +/// +/// #### Other devices +/// +/// `isContinuationSupported()` reports whether this platform can advertise the current state to +/// the user's nearby devices; on Apple platforms it can, elsewhere it cannot and the call is a +/// no-op rather than an error. For everything the platform will not carry -- iOS to Android, two +/// devices that are never together -- set a `StateRelay`, which is your own endpoint. Codename One +/// runs no server for this, because deciding which states belong to the same person is your +/// account system's job. +/// +/// Arriving states are offered to every `ContinuityListener` before anything happens, and this +/// device's own echo is never offered at all. +/// +/// #### Zero cost when unused +/// +/// Referencing this package is what makes the build declare the activity type on Apple platforms +/// and compile the native continuation handling in. An app that never touches +/// `com.codename1.continuity` gets none of it. +public final class Continuity { + /// The `Storage` entry the checkpoint is written to. + static final String STORAGE_KEY = "CN1$Continuity"; + + /// Where this installation's device id lives, so a state can recognize its own echo across + /// restarts. + static final String PREF_DEVICE_ID = "CN1$ContinuityDevice"; + + /// Where the sequence counter lives. Persisted because a counter that restarted at zero would + /// make every state after a relaunch look older than one the receiver had already seen. + static final String PREF_SEQUENCE = "CN1$ContinuitySeq"; + + /// 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 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; + + /// How long a non-EDT caller waits for the EDT to take its capture. + private static final int EDT_WAIT_MILLIS = 2000; + + /// How much longer a caller waits for work the EDT has already STARTED. + /// + /// Separate from the first wait because the two questions differ: the first asks whether the + /// EDT is free at all, and this one waits out an operation that cannot be cancelled. + private static final long EDT_STARTED_CAP_MILLIS = 8000L; + + /// Passed to deliver() by a caller that has no relay session to tie the state to -- a platform + /// continuation, or a test. + private static final long NO_ERA = Long.MIN_VALUE; + + private static final List listeners = new ArrayList(); + + /// Highest sequence seen from each device, so a state delivered twice -- which happens + /// routinely, since a continuation and a relay can carry the same one -- acts once. + private static final Map lastSeen = new HashMap(); + + /// Which run of the framework a delivery belongs to, bumped by `disable()` and `clear()`. + /// + /// A delivery is two steps -- reach the event queue, then dispatch -- and `enabled` alone + /// cannot separate them: an application that disables and re-enables before the queue drains + /// would have the old arrival pass an `enabled` check and restore anyway. Guarded by the + /// STATE_LOCK, which the other half of the same decision already holds. + private static long deliveryEra; + + /// Which run of enable()/disable() the framework is in. Guarded by STATE_LOCK. + /// + /// enable() does slow work -- Preferences, the stored marks -- before it can publish + /// `enabled`, and a disable() arriving during that window has nothing to switch off yet. The + /// generation lets the initializing thread notice it lost and stand down, instead of turning + /// the framework on after the caller was told it was off. + private static long lifecycleEra; + + // Configured by the application while it starts, then read from the EDT, the relay worker + // and the thread a port delivers a continuation on. All guarded by STATE_LOCK -- volatile is + // forbidden by the project's PMD gate, and would not have been enough anyway for the ones + // whose invariant spans more than one read. + 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. Guarded by STATE_LOCK. + private static boolean applyingRestore; + + /// True once a synced-store listener has asked for the inbound seam, independently of + /// `enabled`. Guarded by STATE_LOCK. + private static boolean storeCallbackInstalled; + private static String title; + private static long sequence; + private static long maxAge; + + /// Guards EVERY mutable static in this class. One lock, deliberately. + /// + /// There were three -- one for the handoff fields, one for the relay queue, and the `lastSeen` + /// map's own monitor -- and a set of fields with no lock at all: `enabled`, `relay` and + /// `maxAge` are written by the application and read on the relay worker and on whatever + /// thread a platform hands a continuation over on. The comment above them claimed a lock they + /// did not have. That is not a missing guard on one field, it is the absence of a memory + /// model: every question of the form "can these two steps interleave" had a different answer + /// depending on which of the three locks each step happened to take, so the bugs arrived one + /// interleaving at a time and fixing them one at a time added another flag each round. + /// + /// The rule that replaces it is short enough to keep: touch a mutable static only while + /// holding this, and never call out -- to a listener, a provider, a relay, Storage or the + /// EDT -- while holding it. Read what is needed into locals, release, then act. The second + /// half is what keeps one lock from being a deadlock, and it is why nothing below wraps a + /// call to application code. + private static final Object STATE_LOCK = new Object(); + + /// The device id, lazily created. Guarded by STATE_LOCK. + private static String deviceId; + + /// Whether a checkpoint is owed. Guarded by STATE_LOCK, because Android asks this from its + /// own main thread on the suspend path and a stale "no" there loses the last edit -- which is + /// the one thing the question exists to protect. + private static boolean dirty; + + /// True while a thread is waiting for the first form. Guarded by STATE_LOCK: it is cleared + /// by that thread and read on the EDT, and a stale "true" would leave a parked state with + /// nobody left to deliver it. + private static boolean waitingForWindow; + + /// A state that arrived and could not be shown yet. Guarded by STATE_LOCK. + private static AppState parked; + + /// The delivery era `parked` arrived in, or NO_ERA. Guarded by STATE_LOCK. + private static long parkedEra; + + 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() { + final long generation; + synchronized (STATE_LOCK) { + if (enabled) { + return; + } + generation = lifecycleEra; + } + // 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); + // Loaded OUTSIDE the lock -- both touch Preferences, and nothing slow runs under + // STATE_LOCK -- but BEFORE `enabled` is published, which is the half that matters. + // Publishing the flag first let a second caller see it, return immediately, and checkpoint + // against an uninitialized sequence of 0: that wrote sequence 1, this thread then restored + // the loaded value, and the NEXT checkpoint reused 1. A receiver holding that high-water + // mark discards the second state as one it has already acted on, so a real update never + // arrives on the other device and nothing anywhere says so. + // + // getDeviceId() rather than loadDeviceId(): it is the one that mints and persists a UUID + // atomically, so two threads arriving here cannot end up with two different ids. + String id = getDeviceId(); + long seq = loadSequence(); + // Read BEFORE the flag is published, and merged under the same hold. Restoring them after + // meant another thread could see enabled, poll, and have deliver() admit a state into a + // still-empty map -- the very state this device acted on before the restart -- and the + // merge landing afterwards with an identical sequence does not recall a delivery already + // queued. The duplicate this whole mechanism exists to stop, in the window that creates it. + Map restored = readSeen(); + synchronized (STATE_LOCK) { + if (enabled || generation != lifecycleEra) { + // Lost the race while loading. Either another enable() won -- its values stand, + // and a second callback over theirs is the duplicate the first check exists to + // stop -- or a disable() arrived while this was initializing, and the caller of + // THAT has already been told the framework is off. + return; + } + deviceId = id; + sequence = seq; + for (Map.Entry e : restored.entrySet()) { + Long have = lastSeen.get(e.getKey()); + if (have == null || have.longValue() < e.getValue().longValue()) { + lastSeen.put(e.getKey(), e.getValue()); + } + } + // Published LAST, under the same hold as every piece of state a delivery consults. + enabled = true; + } + ContinuityBridge b = bridgeInternal(); + if (b != null) { + try { + b.setCallback(new Callback()); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Turns the framework off. Checkpoints stop, the advertised activity is withdrawn, and + /// arriving states are ignored. What is already in storage is left alone -- use `clear()` to + /// remove it. + public static void disable() { + // COMMIT_LOCK, like clear(). Without it a checkpoint already past its era check could + // publish the continuation and the relay state AFTER this returned, leaving Handoff + // advertising work while isEnabled() answers false. + synchronized (COMMIT_LOCK) { + synchronized (STATE_LOCK) { + // Bumped even when already disabled, so an enable() that is midway through its + // slow initialization -- loading preferences, before it publishes `enabled` -- + // sees the generation move and stands down. It used to observe false here and + // return, and the initializing thread then switched the framework ON after its + // caller had been told disabling was done. + lifecycleEra++; + if (!enabled) { + return; + } + enabled = false; + // Everything already on the event queue belongs to the run that just ended. + // Bumping the era rather than testing `enabled` at dispatch is what makes + // disable-then-enable safe: a re-enabled framework would otherwise accept an + // arrival from before it was turned off. + deliveryEra++; + dirty = false; + } + setParked(null); + clearContinuation(); + } + } + + /// Whether the framework is on. + /// + /// #### Returns + /// + /// true when enabled + public static boolean isEnabled() { + synchronized (STATE_LOCK) { + 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) { + synchronized (STATE_LOCK) { + provider = p; + } + enable(); + } + + /// The installed state provider, or null. + /// + /// #### Returns + /// + /// the provider + public static StateProvider getStateProvider() { + synchronized (STATE_LOCK) { + return provider; + } + } + + /// Registers a listener for states arriving from elsewhere. + /// + /// #### Parameters + /// + /// - `l`: the listener + public static void addContinuationListener(ContinuityListener l) { + synchronized (STATE_LOCK) { + // Guarded, because the registration API carries no EDT-only contract: an application + // registering from a worker raced the snapshot dispatchLocked() takes on the EDT, so + // a new listener could be missed, a removed one still called, or the copy taken + // mid-mutation. + if (l != null && !listeners.contains(l)) { + listeners.add(l); + } + } + } + + /// Removes a listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public static void removeContinuationListener(ContinuityListener l) { + synchronized (STATE_LOCK) { + 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) { + synchronized (STATE_LOCK) { + // 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. + // + // The same era the account uses, because it means the same thing: the relay session + // this work belonged to is over. + pendingPublish = null; + accountEra++; + relay = r; + } + if (r != null) { + enable(); + pollRelay(); + } + } + + /// The installed relay, or null. + /// + /// #### Returns + /// + /// the relay + public static StateRelay getRelay() { + synchronized (STATE_LOCK) { + 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) { + synchronized (STATE_LOCK) { + autoRestore = b; + } + } + + /// Whether automatic restoration is on. + /// + /// #### Returns + /// + /// true when on + public static boolean isAutoRestore() { + synchronized (STATE_LOCK) { + return autoRestore; + } + } + + /// Sets the label a receiving device may show before the user accepts a continuation -- "Draft + /// to Dana", "Invoice 2031". Update it as the user moves around; it is read at every + /// checkpoint. + /// + /// #### Parameters + /// + /// - `t`: the label, or null for none + public static void setTitle(String t) { + synchronized (STATE_LOCK) { + title = t; + } + } + + /// The current continuation label, or null. + /// + /// #### Returns + /// + /// the label + public static String getTitle() { + synchronized (STATE_LOCK) { + 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) { + synchronized (STATE_LOCK) { + maxAge = millis < 0 ? 0 : millis; + } + } + + /// The staleness limit in milliseconds, or 0 for none. + /// + /// #### Returns + /// + /// the limit + public static long getMaxAge() { + synchronized (STATE_LOCK) { + 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() { + synchronized (STATE_LOCK) { + if (deviceId == null) { + // The ONE place that reads storage under the lock, deliberately. loadDeviceId() + // generates and persists a UUID when there is none, so doing it outside would let + // two threads each generate one: the first writer wins the field and the second + // wins Preferences, and the id then CHANGES across a restart -- which makes every + // state this device ever sent look like it came from somewhere else. Preferences + // never calls back into this class, so holding the lock across it cannot cycle. + deviceId = loadDeviceId(); + } + return deviceId; + } + } + + // ------------------------------------------------------------------ + // Saving + // ------------------------------------------------------------------ + + /// Internal. Called by `com.codename1.router.Navigation` after every change to the navigation + /// stack; schedules a checkpoint rather than taking one, so a burst of navigations costs a + /// single write. + public static void routeStackChanged() { + synchronized (STATE_LOCK) { + 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; + } + dirty = true; + } + if (!Display.isInitialized()) { + return; + } + synchronized (STATE_LOCK) { + // Observed and claimed under one hold. Two route changes in the same cycle both read + // false and both scheduled a flush, so the checkpoint ran twice and published twice. + if (flushScheduled) { + return; + } + flushScheduled = true; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + synchronized (STATE_LOCK) { + 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 (offEdt()) { + runOnEdt(new Runnable() { + @Override + public void run() { + checkpointOnEdt(); + } + }); + return; + } + checkpointOnEdt(); + } + + /// Whether the caller is on a thread that must not touch the navigation stack directly. + private static boolean offEdt() { + return Display.isInitialized() && !Display.getInstance().isEdt(); + } + + /// Runs `r` on the EDT and waits, with a bound. + /// + /// Bounded rather than indefinite because the waiting thread is not always free to block: on + /// the desktop port the EDT itself blocks on the AWT thread while painting, so an application + /// calling a checkpoint from an AWT callback could otherwise deadlock the two against each + /// other. A checkpoint that misses its window is a lost checkpoint; a deadlock is a hung app. + private static boolean runOnEdt(final Runnable r) { + // [0] cancelled, [1] completed. The wait is bounded, and a bounded wait that gives up + // leaves the runnable QUEUED: restore() then returned false to a caller that went on to + // show its initial screen, and the restore ran afterwards and replaced it -- while + // capture() returned null and still consumed a sequence when the EDT got round to it. + // A caller told the operation did not happen has to be right about that. + // [0] cancelled, [1] completed, [2] started. + final boolean[] flags = new boolean[3]; + Runnable guarded = new Runnable() { + @Override + public void run() { + synchronized (flags) { + if (flags[0]) { + return; + } + flags[2] = true; + } + r.run(); + synchronized (flags) { + flags[1] = true; + } + } + }; + try { + Display.getInstance().callSeriallyAndWait(guarded, EDT_WAIT_MILLIS); + } catch (Throwable t) { + Log.e(t); + } + synchronized (flags) { + if (flags[1]) { + return true; + } + if (!flags[2]) { + // Never started: cancelling it is honest, and the caller is told nothing happened. + flags[0] = true; + return false; + } + } + // STARTED and still running. There is nothing to cancel -- the provider or the navigation + // is midway through -- so reporting failure and letting it finish afterwards is the one + // outcome that lies to the caller: restore() returned false, the application showed its + // initial screen, and the restore landed on top of it a moment later. Waiting is the only + // truthful answer, so this waits again, bounded, and only gives up if the operation + // outruns even that. + long deadline = System.currentTimeMillis() + EDT_STARTED_CAP_MILLIS; + while (System.currentTimeMillis() < deadline) { + synchronized (flags) { + if (flags[1]) { + return true; + } + } + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + synchronized (flags) { + return flags[1]; + } + } + + private static void checkpointOnEdt() { + long era; + synchronized (STATE_LOCK) { + if (!enabled) { + return; + } + dirty = false; + era = accountEra; + } + AppState state = capture(); + if (state == null) { + return; + } + // Held across the era check AND the three side effects, so a clear() cannot land between + // them. Building the snapshot is slow -- it calls the application's saveState() -- and the + // state is not in pendingPublish yet, so clear() can neither drop it nor stamp it: without + // this, persisting recreated the storage clear() had just deleted, publishContinuation + // re-advertised the signed-out account's work to the devices around it, and the relay + // publish went out under the NEXT account's credentials. + synchronized (COMMIT_LOCK) { + synchronized (STATE_LOCK) { + if (era != accountEra || !enabled) { + // `enabled` as well as the era: disable() takes COMMIT_LOCK, so a checkpoint + // either commits entirely before it gets in or sees the framework switched + // off here -- rather than advertising work after isEnabled() went false. + return; + } + } + persist(state); + 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() { + synchronized (STATE_LOCK) { + 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. + /// + /// #### Returns + /// + /// the current state, or null when the framework is not enabled + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the provider returned an unrepresentable payload + public static AppState capture() { + if (offEdt()) { + // The navigation stack is EDT-owned and StateProvider.saveState() documents that it + // runs on the EDT. This is public and cheap, so an application calling it from a + // network callback is ordinary -- and it then read the stack while the EDT was + // mutating it and ran the provider on the wrong thread, which is a torn snapshot + // rather than an error anyone would see. + final AppState[] out = new AppState[1]; + runOnEdt(new Runnable() { + @Override + public void run() { + out[0] = captureOnEdt(); + } + }); + return out[0]; + } + return captureOnEdt(); + } + + private static AppState captureOnEdt() { + StateProvider p; + synchronized (STATE_LOCK) { + if (!enabled) { + return null; + } + p = provider; + } + AppState state = new AppState(); + state.setRoutes(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 one. + Log.e(t); + } + if (payload != null) { + // NOT caught. An unrepresentable value is a programming error with exactly one + // correct moment to surface -- here, naming the key -- rather than as a payload + // that silently stops arriving on the other device. + state.setPayload(payload); + } + } + long seq; + String label; + synchronized (STATE_LOCK) { + sequence = nextSequence(); + seq = sequence; + 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. + rememberSequence(seq); + 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() { + AppState waiting; + synchronized (STATE_LOCK) { + waiting = parked; + } + if (waiting != null) { + // Aged like a stored one. A parked state is one that arrived from elsewhere and could + // not be shown yet -- during a cold launch, say -- and time passes 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. + if (isTooOld(waiting)) { + // Cleared, and then we keep looking. Returning null here reported "nothing to + // restore" while a perfectly valid local checkpoint sat in storage -- which is + // ordinary with automatic restore off and the user still navigating -- so a + // single restore() call told the application to show its initial screen instead. + // + // Compare-and-clear, like the restore path. This can run on a worker, and a + // delivery can replace the slot with a NEWER state between the snapshot above and + // this line -- the unconditional clear then deleted that one, while its in-memory + // high-water mark stopped the relay offering it again for the rest of the + // process. Only the state actually inspected is discarded. + synchronized (STATE_LOCK) { + if (isSameState(parked, waiting)) { + parked = null; + parkedEra = NO_ERA; + } + } + } else { + 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) { + long limit; + synchronized (STATE_LOCK) { + limit = maxAge; + } + return limit > 0 && state.getTimestamp() > 0 + && System.currentTimeMillis() - state.getTimestamp() > limit; + } + + /// 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: an off-EDT caller whose marshalled restore timed out got false back, and the + // state was gone -- 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 shown = restore(state); + if (shown) { + synchronized (STATE_LOCK) { + // Compare-and-clear, not a blind clear. An off-EDT caller takes state A and waits + // while the EDT restores it, and a delivery queued behind that can park a NEWER + // state B in the meantime -- clearing the slot then threw B away, and the + // in-memory high-water mark stopped the relay offering it again for the rest of + // the session. + // + // By (device, sequence) rather than by reference. That pair is how this class + // identifies a state everywhere else -- it is what lastSeen keys on and what the + // echo check uses -- so two objects carrying it ARE the same state, which a + // reference test would have missed. The project's PMD gate forbids == on objects + // for exactly this reason. + if (isSameState(parked, state)) { + parked = null; + parkedEra = NO_ERA; + } + } + } + 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; + } + 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) { + if (state == null) { + return false; + } + if (offEdt()) { + // Same reason capture() and checkpoint() marshal: this builds and shows forms through + // Navigation.restoreStack() and calls StateProvider.restoreState(), both of which are + // EDT work, and the method is public enough that an application restoring from its own + // transport's callback is ordinary. + final boolean[] out = new boolean[1]; + runOnEdt(new Runnable() { + @Override + public void run() { + out[0] = restoreOnEdt(state); + } + }); + return out[0]; + } + return restoreOnEdt(state); + } + + private static boolean restoreOnEdt(AppState state) { + StateProvider p = provider; + if (p != null) { + try { + // Before the routes, so a form the route table is about to build can read what + // the provider stashed while it is being constructed. + p.restoreState(state.getPayload()); + } catch (Throwable t) { + Log.e(t); + } + } + List routes = state.getRoutes(); + if (routes.isEmpty()) { + // Payload-only restoration, which is what an app that does not use @Route gets. The + // provider 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. + return false; + } + boolean shown; + synchronized (STATE_LOCK) { + // 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. + // + // A plain field because restoration is an EDT activity: restoreStack() builds forms + // and shows one. Two threads restoring at once is already broken for that reason. + applyingRestore = true; + } + try { + shown = Navigation.restoreStack(routes); + } catch (Throwable t) { + Log.e(t); + shown = false; + } finally { + synchronized (STATE_LOCK) { + applyingRestore = false; + } + } + if (shown) { + // Locally, and only locally. Suppressing the checkpoint above also suppressed the + // write that records where the user now is, and without this a cold start would come + // back to the position that preceded the restore. + persist(state); + } + // Acknowledged whenever the state was APPLIED, which is not the same question as whether + // a form appeared. A route-less continuation is applied by handing its payload to the + // provider -- the documented shape for an app that does not use @Route -- and + // restoreStack() then returns false, so tying the acknowledgement to the return value + // left that state unmarked: the relay offered it again after every restart, and with + // automatic restore off the no-argument wrapper re-applied it on every call. Calling + // restore() IS the acceptance; what it returns only says whether the caller still needs + // to show a screen. + noteActedOn(state); + 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() { + final StateRelay r; + synchronized (STATE_LOCK) { + r = relay; + if (r == null || !enabled) { + return; + } + } + if (!Display.isInitialized()) { + return; + } + // Anything owed goes out first. This is the natural moment for it -- the application + // calls this when it reconnects, and Android calls it on resume -- and without it a state + // retained after a failed send had no way back onto the wire. + // + // Started, not waited for, and a review asked for the opposite: serialize the fetch + // behind the publication so the GET cannot read a document the pending POST is about to + // replace. Waiting would be worse than the race it closes. + // + // A relay holds ONE document per user, so a fetch that waits for our own publish reads + // back our own write -- every time. The other device's state would be overwritten before + // it was ever seen, and polling would stop working for the case it exists to serve. + // + // The race itself is benign in the shape described. What the GET can return early is the + // copy of THIS device's own earlier state, and deliver() drops that as an echo before it + // reaches a listener. A genuinely different device's state is not made older or newer by + // when our publish lands; ordering between devices is per-device sequences, maxAge and + // the listener's own answer, none of which this would change. + // NOT startPublisher() here. 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. The retained publish is started when the poll finishes, below, which is the only + // ordering that both sends what is owed and reads what is there. + synchronized (STATE_LOCK) { + 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, and answering it with + // silence would be the same lost-request bug the publisher had. + pollAgain = true; + return; + } + polling = true; + } + Display.getInstance().startThread(new Runnable() { + @Override + public void run() { + try { + for (;;) { + boolean standDown = false; + pollOnce(); + synchronized (STATE_LOCK) { + if (!pollAgain) { + // Observed and stood down under ONE hold, for the reason the + // publisher documents: releasing the lock between the two would + // let a poll requested in the gap set a flag nobody ever reads. + polling = false; + standDown = true; + } else { + pollAgain = false; + } + } + if (standDown) { + // Owed work goes out AFTER the fetch, never before it -- and OUTSIDE + // the lock, because startPublisher() spawns a thread and nothing slow + // or re-entrant may run under STATE_LOCK. + startPublisher(); + return; + } + } + } catch (Throwable t) { + // Nothing below is expected to throw -- pollOnce() catches the relay's own + // failures -- but leaving the flag set would silently stop every future poll + // for the life of the process. + Log.e(t); + synchronized (STATE_LOCK) { + polling = false; + } + } + } + }, "Continuity relay poll").start(); + } + + /// One relay fetch and, if it is worth it, one delivery. Returning early ends this attempt, + /// never the polling loop -- which is why the stand-down lives in the caller. + private static void pollOnce() { + final long era; + final StateRelay r; + synchronized (STATE_LOCK) { + // Relay and era read as a PAIR, on every attempt. The worker used to keep the relay + // it was started with and refresh only the era, so a poll coalesced behind a + // setRelay() fetched from the endpoint that had just been REPLACED and stamped the + // answer with the new era -- which made the era check, whose whole job is to stop + // exactly that, wave it through and restore the old endpoint's data. + era = accountEra; + r = relay; + if (r == null) { + return; + } + } + AppState fetched = null; + try { + fetched = r.fetch(); + } catch (Throwable t) { + Log.e(t); + return; + } + if (fetched == null) { + return; + } + // The era travels WITH the state rather than being checked here and hoped for: a logout + // landing between this line and the admission inside deliver() would otherwise rebrand the + // previous account's response as a current-session arrival, and clear() has just emptied + // lastSeen so nothing downstream would know better. + deliver(fetched, era); + } + + /// 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. 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() { + synchronized (COMMIT_LOCK) { + clearLocked(); + } + } + + private static void clearLocked() { + setParked(null); + synchronized (STATE_LOCK) { + dirty = false; + } + synchronized (STATE_LOCK) { + // 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. Dropped, + // and the era bumped so a publisher that is midway through a request stands down + // instead of taking the next one. + // + // 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. + pendingPublish = null; + accountEra++; + } + synchronized (STATE_LOCK) { + // Under STATE_LOCK, which deliver() and stillDeliverable() use too. A bare clear() on a + // HashMap that another thread is reading is a data race, not merely a stale read -- + // and the benign-looking version of it let a pre-logout high-water mark survive long + // enough for a queued delivery to pass isStillNewest and dispatch the previous + // account's state after the user signed out. + lastSeen.clear(); + deliveryEra++; + } + // The durable copy as well. 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(); + clearContinuation(); + try { + if (Display.isInitialized() && Storage.getInstance().exists(STORAGE_KEY)) { + Storage.getInstance().deleteStorageFile(STORAGE_KEY); + } + } catch (Throwable t) { + Log.e(t); + } + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private static List currentRoutes() { + List paths = new ArrayList(); + List stack; + try { + stack = Navigation.getStack(); + } catch (Throwable t) { + // Only the call is guarded. Walking the list has to sit outside, because the compiler + // inserts a checked cast per element for the generic type -- and a failed cast does + // not throw on the iOS virtual machine, so a handler wrapped around one is a handler + // that cannot run there. See the ClassCastException note in CLAUDE.md. + Log.e(t); + return paths; + } + for (com.codename1.router.NavigationEntry entry : stack) { + paths.add(entry.getPath()); + } + return paths; + } + + private static void persist(AppState state) { + try { + Storage.getInstance().writeObject(STORAGE_KEY, state); + } catch (Throwable t) { + Log.e(t); + } + } + + /// Writes the sequence counter so it keeps rising across a relaunch. + private static void rememberSequence(long seq) { + try { + Preferences.set(PREF_SEQUENCE, seq); + } catch (Throwable t) { + Log.e(t); + } + } + + private static AppState readStored() { + try { + if (!Display.isInitialized() || !Storage.getInstance().exists(STORAGE_KEY)) { + return null; + } + Object o = Storage.getInstance().readObject(STORAGE_KEY); + // instanceof rather than a cast: a failed cast does not throw on the iOS virtual + // machine, so the wrong object would be handed to the next instruction instead of + // reaching a catch. A stored entry of another shape is possible after a downgrade. + if (o instanceof AppState) { + return (AppState) o; + } + return null; + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + private static void publishContinuation(AppState state) { + ContinuityBridge b = bridgeInternal(); + if (b == null) { + return; + } + try { + if (!b.isContinuationSupported()) { + return; + } + if (state.isEmpty()) { + b.clearContinuation(); + return; + } + b.publishContinuation(getActivityType(), state.getTitle(), StateCodec.toMap(state)); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void clearContinuation() { + ContinuityBridge b = bridgeInternal(); + if (b == null) { + return; + } + try { + if (b.isContinuationSupported()) { + b.clearContinuation(); + } + } catch (Throwable t) { + Log.e(t); + } + } + + /// 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 single publisher thread is alive. Guarded by STATE_LOCK. + private static boolean publishing; + + /// Which signed-in session the relay work belongs to, bumped by `clear()`. + /// + /// Both directions need it. A publisher reads it with the state it dequeues, so a state taken + /// before a logout is not sent after one; and a poll reads it before it asks, so a result that + /// was already in flight when the user signed out is not delivered into the next account's + /// session. Guarded by STATE_LOCK. + private static long accountEra; + + /// True while a relay fetch is in flight; `pollAgain` records a poll asked for during one. + /// Both guarded by STATE_LOCK. + private static boolean polling; + + private static boolean pollAgain; + + /// True when someone asked for a publisher while one was already running. + /// + /// The publisher deliberately does not retry in a loop -- one attempt per change, rather than + /// a spin against a dead endpoint -- but a request that arrived DURING an attempt is a new + /// signal rather than a spin, and pollRelay() on reconnect is exactly that. Guarded by + /// STATE_LOCK. + private static boolean publishRequested; + + /// Hands a state to the relay, in order, one at a time. + /// + /// A thread per checkpoint was a race with a silent and durable result: two checkpoints in + /// quick succession raced to the same endpoint, and because a publish replaces the stored + /// document, the slower OLDER request could land last and leave the user's other device + /// fetching work they had already moved past. Nothing failed and nothing was logged. + private static void publishToRelay(AppState state) { + if (!Display.isInitialized()) { + return; + } + synchronized (STATE_LOCK) { + if (relay == null) { + return; + } + pendingPublish = state; + } + startPublisher(); + } + + /// Starts the single publisher, if there is work and nobody is doing it. + /// + /// Separate from `publishToRelay` because a checkpoint is not the only thing that should + /// start one. A state retained after a failed send would otherwise sit in the queue forever: + /// the only caller was `checkpoint()`, and a checkpoint OVERWRITES the pending slot with its + /// own newer state before starting anything -- so the retained one could never be sent, and + /// keeping it was an empty gesture. `pollRelay()` calls this too, which gives it a real + /// second chance at the moment an application already reconnects. + /// + /// The stand-down inside the worker re-reads the pending slot under the same lock, so a + /// state queued between these two lock holds is either seen by the live worker or starts a + /// new one -- never dropped between them. + private static void startPublisher() { + if (!Display.isInitialized()) { + return; + } + synchronized (STATE_LOCK) { + if (relay == null || publishing || pendingPublish == null || polling) { + 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. The + // earlier fix deferred only the retained work pollRelay() itself starts; + // a checkpoint arriving mid-poll still published straight over it. The poll + // starts a publisher when it finishes. + publishRequested = true; + return; + } + if (publishing) { + // Remembered rather than dropped. The live publisher picks up whatever is + // queued when it finishes, which is what makes the ordering total -- but if + // its current attempt FAILS it requeues and stands down, and this request + // would have been forgotten. A single reconnect after a failed send then left + // the retained state unsent until some later checkpoint happened. + publishRequested = true; + } + return; + } + publishing = true; + } + Display.getInstance().startThread(new Runnable() { + @Override + public void run() { + try { + for (;;) { + StateRelay r; + AppState next; + long era; + synchronized (STATE_LOCK) { + r = relay; + next = pendingPublish; + if (r == null || next == null) { + // Observing no work and standing down happen under ONE hold of + // the lock, and that is the whole correctness argument. An + // earlier version cleared the flag, released, and then re-queued + // what it found -- so a checkpoint landing in that gap started a + // second publisher, and the re-queue then overwrote its newer + // state with the older one. The relay's last value was stale and + // nothing said so. + publishing = false; + return; + } + pendingPublish = null; + era = accountEra; + } + synchronized (STATE_LOCK) { + if (era != accountEra) { + // clear() ran between taking this state off the queue and + // reaching the send. Dequeued-but-not-yet-sent is recallable and + // already-on-the-wire is not, and an earlier version of this + // reasoning treated them as the same thing -- so the old + // account's state went out after logout, under whatever + // credentials the relay resolved by then. + // + // Still not atomic with the network call, and it cannot be: a + // clear() landing after this check is the in-flight case, which + // clear()'s own documentation says it cannot undo. This closes + // the half that was never in flight at all. + // + // Back to the top rather than standing down, and the difference + // is a state that never gets sent. clear() can be followed by a + // checkpoint on the NEW account: publishToRelay() queues it, sees + // publishing == true, and leaves it for this worker on the + // understanding that a live worker always drains the slot. + // Clearing the flag and returning here broke that promise and + // stranded the new account's only checkpoint until something + // else happened to start a publisher. The loop's first block + // re-dequeues under one lock and stands down properly when there + // is genuinely nothing left. + continue; + } + } + try { + r.publish(next); + } catch (Throwable t) { + Log.e(t); + // 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, and only for the session + // it belongs to. Standing down afterwards rather than retrying in a + // loop: the next checkpoint starts a publisher and sends it, which is + // one attempt per change instead of a spin against a dead endpoint. + synchronized (STATE_LOCK) { + if (era == accountEra && pendingPublish == null) { + pendingPublish = next; + if (!publishRequested) { + publishing = false; + return; + } + // Somebody asked for a publisher while this attempt was in + // flight -- an application calling pollRelay() on reconnect is + // the ordinary case -- and startPublisher() left it to this + // worker. Consumed rather than looped on: only an external + // call sets it again, so this is one extra attempt per + // request and not the spin the stand-down exists to avoid. + publishRequested = false; + } + } + } + // No era check here, deliberately. clear() empties the queue, so anything + // present now was queued by the session that is signed in NOW and has to + // be sent. An earlier version stood down on an era change and stranded + // exactly that state until some later checkpoint happened to restart the + // worker. + } + } catch (Throwable fatal) { + // Nothing above is expected to throw -- the publish is already guarded -- but + // a publisher that died holding the flag would stop every later checkpoint + // from ever reaching the relay again. + synchronized (STATE_LOCK) { + publishing = false; + } + Log.e(fatal); + } + } + }, "Continuity relay publish").start(); + } + + /// The activity type this app publishes and answers to, which is the app's package name + /// followed by `.continuity`. + /// + /// Fixed by the build, which declares the same string to the platform in `NSUserActivityTypes`; + /// the two have to agree or the operating system refuses to deliver anything. Exposed because + /// an app that also publishes activities of its own needs to know which one is this + /// framework's, and because it is the first thing to check when a continuation never arrives. + /// + /// #### Returns + /// + /// the activity type, never null + public static String getActivityType() { + String pkg = null; + try { + pkg = Display.getInstance().getProperty("package_name", null); + } catch (Throwable t) { + Log.e(t); + } + if (pkg == null || pkg.length() == 0) { + pkg = "com.codename1.app"; + } + return pkg + ".continuity"; + } + + /// Routes an arriving state to the application, from whatever channel produced it. + static void deliver(final AppState state) { + deliver(state, NO_ERA); + } + + /// As above, for a state fetched in a known relay session. + /// + /// The era is CARRIED rather than checked beforehand. A poll that validated the era, released + /// the lock and then delivered was a check-then-act: clear() landing in that gap admitted the + /// previous account's response under the new deliveryEra and the freshly emptied lastSeen, so + /// it restored into the account that had just signed in. Passing it here puts the question in + /// the same hold as the admission it governs. + static void deliver(final AppState state, final long pollEra) { + if (state == null) { + return; + } + synchronized (STATE_LOCK) { + if (!enabled) { + return; + } + if (pollEra != NO_ERA && pollEra != accountEra) { + return; + } + } + if (getDeviceId().equals(state.getDeviceId())) { + // This device's own echo, which a relay returns as a matter of course. + return; + } + if (isTooOld(state)) { + // Checked here rather than only on the stored path. A relay hands back whatever it + // still holds, which can be days old, and an expired checkout or booking hold that + // auto-restored was 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; + } + final long era; + synchronized (STATE_LOCK) { + // Re-asked under the SAME hold that records the mark, so a logout between the check + // above and this one cannot slip a previous-account state past both. + if (pollEra != NO_ERA && pollEra != accountEra) { + return; + } + Long seen = lastSeen.get(state.getDeviceId()); + if (seen != null && seen.longValue() >= state.getSequence()) { + return; + } + lastSeen.put(state.getDeviceId(), Long.valueOf(state.getSequence())); + era = deliveryEra; + } + + if (!Display.isInitialized()) { + if (stillDeliverable(state, era)) { + setParked(state); + } + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + // Rechecked here, not only above. Recording the high-water mark and reaching this + // queue are two steps, and two channels -- a continuation and a relay poll -- + // deliver on threads of their own: an older state could pass the check, 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. + if (stillDeliverable(state, era)) { + dispatch(state, era); + } + } + }); + } + + /// Whether a delivery queued in `era` should still act: the framework has not been turned off + /// or logged out since, and nothing newer from that device has overtaken it. + /// + /// One predicate rather than two. It replaced a separate "is this still the newest" check, and + /// leaving that behind would have been a private method nobody calls -- which the SpotBugs + /// gate refuses, correctly: the two questions are always asked together and answering them + /// under one hold of the monitor is also what keeps them consistent with each other. + private static boolean stillDeliverable(AppState state, long era) { + synchronized (STATE_LOCK) { + if (era != deliveryEra) { + return false; + } + Long seen = lastSeen.get(state.getDeviceId()); + return seen != null && seen.longValue() == state.getSequence(); + } + } + + /// Applies an arrival: offers it to the listeners, then restores or parks it. + /// + /// The era is always supplied. There was a convenience overload passing NO_ERA, and the + /// cold-launch waiter used it -- which is precisely how the revalidation inside COMMIT_LOCK + /// came to be bypassed on the one path where the wait is longest. Removing it means the + /// question cannot be skipped by accident, and SpotBugs refuses an uncalled private method + /// anyway. + private static void dispatch(AppState state, long era) { + // COMMIT_LOCK for the whole dispatch, which is what actually serializes it against + // clear(). stillDeliverable() checked the era and released STATE_LOCK, so a logout landing + // after that let this run listeners, restore navigation and persist the PREVIOUS account's + // state after the user had signed out -- the era check cannot help once it is behind us. + // + // Yes, this holds a lock across application code, which STATE_LOCK never does. The other + // holders are clear() and the checkpoint commit: the commit runs on the EDT, as this does, + // so it is the same thread and reentrant; clear() is short and rare. A listener that + // blocks on a THREAD that wants COMMIT_LOCK would stall, and that is the price of a logout + // being able to stop a restore it has already superseded. + synchronized (COMMIT_LOCK) { + synchronized (STATE_LOCK) { + if (era != NO_ERA && era != deliveryEra) { + // Re-asked HERE, after the lock is held. stillDeliverable() answered before + // COMMIT_LOCK was taken, so a clear() that got the lock first completed while + // this was still queued -- and taking the lock afterwards without re-checking + // dispatched the previous account's state anyway. A lock around a stale answer + // is not serialization. + return; + } + } + dispatchLocked(state, era); + } + } + + private static void dispatchLocked(AppState state, long era) { + if (isTooOld(state)) { + // Checked HERE and not only on arrival, because arrival 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 dispatches it directly -- so a state that + // was fresh when it landed and expired during that wait was auto-restored anyway, + // past both the inbound check and the one in getRestorableState(). An expired + // checkout or booking is exactly what maxAge exists to refuse. + 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, era); + return; + } + // A copy, because a listener that reacts by unregistering itself is ordinary and would + // otherwise mutate the list being walked. + List snapshot; + synchronized (STATE_LOCK) { + snapshot = new ArrayList(listeners); + } + for (ContinuityListener l : snapshot) { + boolean accepted; + try { + accepted = l.stateReceived(state); + } catch (Throwable t) { + Log.e(t); + continue; + } + if (!accepted) { + // Consumed by the listener: it either handled the state itself or decided the user + // must not be moved. Asking the next listener would undo that decision. + return; + } + } + boolean auto; + synchronized (STATE_LOCK) { + auto = autoRestore; + } + if (auto) { + restore(state); + // Durable only NOW, and only on the branch that actually consumed the state. Writing + // it at admission meant a process killed before this runnable ran left a high-water + // mark for a state nothing had acted on -- and writing it on the PARKED branch below + // was the same bug one step further along: `parked` is a field, so a process killed + // before the application calls restore() loses the state while the mark survives, and + // the relay's repeat is rejected on the next launch. The parked branch gets its mark + // from restore() itself, through noteActedOn, when the application accepts it. The + // in-memory mark still goes in at admission, which is what dedups within a session. + rememberSeen(); + } else { + // Parked with its era, so the application accepting it later is still checked against + // the run it arrived in. + setParked(state, era); + } + } + + private static void park(final AppState state, long era) { + setParked(state, era); + synchronized (STATE_LOCK) { + if (waitingForWindow) { + return; + } + waitingForWindow = true; + } + Display.getInstance().startThread(new Runnable() { + @Override + public void run() { + long deadline = System.currentTimeMillis() + WINDOW_WAIT_MILLIS; + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(100); + } catch (InterruptedException err) { + break; + } + if (Display.getInstance().getCurrent() != null) { + break; + } + } + synchronized (STATE_LOCK) { + waitingForWindow = false; + } + if (Display.getInstance().getCurrent() == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + // Taken and cleared rather than compared against the state this + // waiter was started for. A newer arrival while it waited is the one + // worth showing, and identity comparison would have discarded it. + AppState waiting; + long waitingEra; + synchronized (STATE_LOCK) { + waiting = parked; + waitingEra = parkedEra; + parked = null; + parkedEra = NO_ERA; + } + if (waiting != null) { + dispatch(waiting, waitingEra); + } + } + }); + } + }, "Continuity window wait").start(); + } + + private static String loadDeviceId() { + try { + String id = Preferences.get(PREF_DEVICE_ID, null); + if (id == null || id.length() == 0) { + id = Util.getUUID(); + Preferences.set(PREF_DEVICE_ID, id); + } + return id; + } catch (Throwable t) { + Log.e(t); + // A device with no readable preferences still has to have an id, or every state it + // produces would look like every other device's. Unstable across restarts, which + // costs only some duplicate deliveries. + return "cn1-" + System.currentTimeMillis(); + } + } + + /// Serializes a checkpoint's side effects against clear(). + /// + /// An era recheck before them was still a check-then-act: clear() completing after the + /// comparison released STATE_LOCK left the checkpoint free to recreate the storage that had + /// just been deleted, re-advertise the signed-out account's work, and queue it under the new + /// account's credentials. The three side effects cannot be done while holding STATE_LOCK -- + /// they write Storage and call the platform bridge, and nothing slow may run under it -- so + /// they take this instead, and clear() takes it for its whole body. + /// + /// Lock order is COMMIT_LOCK then SEEN_LOCK then STATE_LOCK, everywhere, and nothing acquires + /// them in any other order. + private static final Object COMMIT_LOCK = new Object(); + + /// Serializes the durable write of the high-water marks. See rememberSeen(). + private static final Object SEEN_LOCK = new Object(); + + /// Whether two states are the same one: same origin device, same sequence. + /// + /// The pair that identifies a state throughout this class. Neither half alone will do -- + /// sequences restart at zero on a device whose preferences were cleared, and one device + /// publishes many. + private static boolean isSameState(AppState a, AppState b) { + if (a == null || b == null) { + return false; + } + String left = a.getDeviceId(); + String right = b.getDeviceId(); + return left != null && left.equals(right) && a.getSequence() == b.getSequence(); + } + + /// 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; + } + synchronized (STATE_LOCK) { + Long seen = lastSeen.get(from); + if (seen == null || seen.longValue() < state.getSequence()) { + lastSeen.put(from, Long.valueOf(state.getSequence())); + } + } + // ALWAYS, not only when the in-memory map moved. That condition was written when the + // durable copy tracked memory exactly; it no longer does -- the mark goes into memory at + // admission and reaches disk only when the state is acted on -- so by the time anything + // calls this, memory already holds the entry and "unchanged" meant "write nothing". Both + // acknowledge() and the restore path were silently persisting nothing at all. + rememberSeen(); + } + + /// Reads the persisted high-water marks. Never null. + private static Map readSeen() { + Map out = new HashMap(); + try { + String raw = Preferences.get(PREF_SEEN, ""); + if (raw == null || raw.length() == 0) { + return out; + } + // "id|seq;id|seq". A device id is a UUID or a "cn1-" fallback, so neither separator + // can occur inside one -- and a malformed entry is skipped rather than throwing, + // because a corrupt preference must cost a duplicate delivery and not a launch. + int from = 0; + while (from < raw.length()) { + int end = raw.indexOf(';', from); + String entry = end < 0 ? raw.substring(from) : raw.substring(from, end); + int bar = entry.indexOf('|'); + if (bar > 0 && bar < entry.length() - 1) { + try { + out.put(entry.substring(0, bar), + Long.valueOf(Long.parseLong(entry.substring(bar + 1)))); + } catch (NumberFormatException ignored) { + // Skipped, as above. + } + } + if (end < 0) { + break; + } + from = end + 1; + } + } catch (Throwable t) { + Log.e(t); + } + return out; + } + + /// 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() { + // SEEN_LOCK first and held across both the snapshot and the write, so the preference can + // only move forwards. Snapshotting outside it let two inbound channels interleave: the + // older snapshot -- carrying one device -- could land after the newer one carrying two, + // and the second device's mark vanished from disk while memory still looked right, so its + // state was acted on again after the next restart. + // + // Always SEEN_LOCK then STATE_LOCK, never the reverse: every caller reaches here with no + // lock held, so there is no cycle to close. + synchronized (SEEN_LOCK) { + rememberSeenLocked(); + } + } + + private static void rememberSeenLocked() { + Map copy; + synchronized (STATE_LOCK) { + copy = new HashMap(lastSeen); + } + while (copy.size() > MAX_SEEN) { + String lowest = null; + long lowestSeq = Long.MAX_VALUE; + for (Map.Entry e : copy.entrySet()) { + if (e.getValue().longValue() < lowestSeq) { + lowestSeq = e.getValue().longValue(); + lowest = e.getKey(); + } + } + if (lowest == null) { + break; + } + copy.remove(lowest); + } + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : copy.entrySet()) { + if (sb.length() > 0) { + sb.append(';'); + } + sb.append(e.getKey()).append('|').append(e.getValue().longValue()); + } + // ONLY the write is guarded. 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 { + Preferences.set(PREF_SEEN, sb.toString()); + } catch (Throwable t) { + Log.e(t); + } + } + + private static long loadSequence() { + try { + return Preferences.get(PREF_SEQUENCE, (long) 0); + } catch (Throwable t) { + Log.e(t); + return 0; + } + } + + private static long nextSequence() { + return sequence + 1; + } + + /// Test seam: installs a bridge, bypassing platform resolution. + /// + /// #### Parameters + /// + /// - `b`: the bridge, or null to resolve from the platform again + public static void setBridge(ContinuityBridge b) { + boolean on; + synchronized (STATE_LOCK) { + bridge = b; + bridgeOverridden = b != null; + on = enabled; + } + if (b != null && on) { + try { + b.setCallback(new Callback()); + } catch (Throwable t) { + Log.e(t); + } + } + } + + /// Internal. The resolved platform bridge, for `com.codename1.continuity.sync`, which is a + /// package of its own so that its entitlement is earned separately. Application code uses + /// `com.codename1.continuity.sync.SyncedStore`. + /// + /// #### Returns + /// + /// the bridge, or null when this port has none + public static ContinuityBridge bridgeForSyncedStore() { + return bridgeInternal(); + } + + /// Internal. 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() { + synchronized (STATE_LOCK) { + storeCallbackInstalled = true; + } + ContinuityBridge b = bridgeInternal(); + if (b == null) { + return; + } + try { + b.setCallback(new Callback()); + } catch (Throwable t) { + Log.e(t); + } + } + + /// 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() { + boolean wanted; + synchronized (STATE_LOCK) { + // 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. + wanted = enabled || storeCallbackInstalled; + } + if (!wanted) { + return; + } + ContinuityBridge b = bridgeInternal(); + if (b == null) { + return; + } + try { + b.setCallback(new Callback()); + } catch (Throwable t) { + Log.e(t); + } + } + + static ContinuityBridge bridgeInternal() { + synchronized (STATE_LOCK) { + if (bridgeOverridden) { + return bridge; + } + } + if (!Display.isInitialized()) { + return null; + } + try { + return Display.getInstance().getContinuityBridge(); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + /// Test seam: returns the framework to its untouched state. + static void reset() { + synchronized (STATE_LOCK) { + listeners.clear(); + } + synchronized (STATE_LOCK) { + lastSeen.clear(); + deliveryEra++; + } + synchronized (STATE_LOCK) { + provider = null; + relay = null; + bridge = null; + bridgeOverridden = false; + enabled = false; + autoRestore = true; + flushScheduled = false; + title = null; + sequence = 0; + maxAge = 0; + deviceId = null; + parked = null; + dirty = false; + waitingForWindow = false; + applyingRestore = false; + storeCallbackInstalled = false; + } + synchronized (STATE_LOCK) { + pendingPublish = null; + polling = false; + pollAgain = false; + publishRequested = false; + } + // `publishing` was missing from every list above, and the publisher is a LIVE thread: the + // relay going null only makes it stand down at its next dequeue. So the flag stayed true + // across a reset, the next caller's startPublisher() saw a publisher already running and + // returned, and nothing was ever sent again -- a relay whose last value is an old + // checkpoint while newer ones sit in the slot unread. + // + // Waited for rather than force-cleared. Clearing it under a running worker lets a second + // one start, and two publishers interleaving is the out-of-order relay the single-worker + // design exists to prevent. Bounded, because a wedged worker must not wedge this too. + long deadline = System.currentTimeMillis() + 2000L; + for (;;) { + synchronized (STATE_LOCK) { + if (!publishing || System.currentTimeMillis() > deadline) { + publishing = false; + break; + } + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + synchronized (STATE_LOCK) { + publishing = false; + } + break; + } + } + } + + private static void setParked(AppState state) { + setParked(state, NO_ERA); + } + + /// Parks `state`, remembering which run of the framework it arrived in. + /// + /// The era travels WITH it. The cold-launch waiter unparks and dispatches minutes later, and + /// dispatching through the era-less overload bypassed the revalidation inside COMMIT_LOCK -- + /// so a clear() during the wait let the previous account's listeners, navigation and + /// persistence run after logout, which is the one thing that revalidation exists to stop. + private static void setParked(AppState state, long era) { + synchronized (STATE_LOCK) { + parked = state; + parkedEra = state == null ? NO_ERA : era; + } + } + + /// The inbound seam handed to the port's bridge. + static final class Callback implements ContinuityCallback { + @Override + public boolean continuationReceived(String activityType, Map userInfo) { + if (!enabled || activityType == null || !activityType.equals(getActivityType())) { + // Not ours. Answering honestly is what keeps a Handoff or third-party activity + // this app never published from being swallowed by a handler that would do + // nothing with it. + return false; + } + AppState state = StateCodec.fromMap(userInfo); + if (state == null) { + return false; + } + deliver(state); + return true; + } + + @Override + public void syncedStoreChanged() { + com.codename1.continuity.sync.SyncedStore.notifyChanged(); + } + } +} diff --git a/CodenameOne/src/com/codename1/continuity/ContinuityListener.java b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java new file mode 100644 index 00000000000..cf308c08447 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/ContinuityListener.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +/// Notified when a state arrives from somewhere other than this device's own storage: one of the +/// user's other devices handed off what they were doing, or a `StateRelay` produced something +/// newer than what is here. +/// +/// Registered with `Continuity.addContinuationListener(ContinuityListener)`. Called on the event +/// dispatch thread, and never for this device's own echo. +public interface ContinuityListener { + /// A state arrived. Return true to let the framework restore it, false to ignore it. + /// + /// Returning false is the hook for the decisions only the app can make -- that the user is + /// midway through a payment and must not be moved, that the state is older than what is on + /// screen, that it belongs to a different account than the one signed in here. A listener + /// that returns false has consumed the state: nothing is restored and no other listener is + /// asked. + /// + /// Doing the work yourself and returning false is a supported pattern, and is how an app + /// prompts before jumping: keep the state, return false, and call + /// `Continuity.restore(AppState)` when the user accepts. + /// + /// 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..3dfa8df882d --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/RestStateRelay.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.io.rest.RequestBuilder; +import com.codename1.io.rest.Response; +import com.codename1.io.rest.Rest; + +import java.io.IOException; + +/// A `StateRelay` over your own HTTPS endpoint, which is all most applications need. +/// +/// ```java +/// Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") { +/// protected String getToken() { +/// return session.getAccessToken(); +/// } +/// }); +/// ``` +/// +/// #### The contract +/// +/// Two requests against the one URL you supply: +/// +/// - `POST` with the state as a JSON body and `Content-Type: application/json`. Store it against +/// the signed-in user, replacing whatever you held for them. Any 2xx means stored. +/// - `GET`, answering with the newest state you hold for that user as the same JSON, or an empty +/// body when you hold none. A 404 also means none. +/// +/// The JSON is exactly what `StateCodec.toJson(AppState)` produces, and it is a closed shape: your +/// endpoint stores and returns the document, and never needs to look inside it. +/// +/// #### Identity is yours +/// +/// Which states belong to the same person is the one question the framework cannot answer, which +/// is why the token comes from `getToken()` rather than from a constructor: it is read at each +/// request, so a session that refreshes its token is followed automatically. Return null for an +/// endpoint that identifies the user some other way -- a cookie, mutual TLS -- and the header is +/// simply omitted. +/// +/// #### Threading +/// +/// Both methods are called from a background thread and block, which is what the framework +/// expects of a relay. `getToken()` is called on that same thread, so it must not wait on the +/// event dispatch thread. +public class RestStateRelay implements StateRelay { + private final String url; + + /// Creates a relay against an HTTPS endpoint. + /// + /// #### Parameters + /// + /// - `url`: the endpoint, which must be HTTPS + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the URL is null, empty or not HTTPS + public RestStateRelay(String url) { + if (url == null || url.length() == 0) { + throw new IllegalArgumentException("A continuity relay needs an endpoint URL."); + } + if (url.length() < 8 || !"https://".equals(url.substring(0, 8).toLowerCase())) { + // Refused rather than passed on. The bearer token goes out on every request and the + // payload is a description of what the user is doing on their other device; an + // "http://" typo would put both on the network in the clear wherever a cleartext + // policy still allows it. + throw new IllegalArgumentException("A continuity relay endpoint must be HTTPS; got \"" + + url + "\"."); + } + this.url = url; + } + + /// The endpoint this relay talks to. + /// + /// #### Returns + /// + /// the URL + public String getUrl() { + return url; + } + + /// The bearer token to present, read once per request. The default returns null, which sends + /// no `Authorization` header. + /// + /// #### Returns + /// + /// the token, or null for none + protected String getToken() { + return null; + } + + @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()); + } + + private RequestBuilder auth(RequestBuilder b) { + String token = getToken(); + return token == null || token.length() == 0 ? b : b.bearer(token); + } +} diff --git a/CodenameOne/src/com/codename1/continuity/StateCodec.java b/CodenameOne/src/com/codename1/continuity/StateCodec.java new file mode 100644 index 00000000000..05969354eb8 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/StateCodec.java @@ -0,0 +1,493 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.io.JSONParser; +import com.codename1.io.JSONWriter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Turns an `AppState` into the two forms it has to travel in, and refuses payloads that cannot +/// make the trip. +/// +/// The two forms are deliberately different. A *continuation* is handed to the operating system, +/// which stores it as a property list and may deliver it to another device, so it is a nested map +/// of plist-representable values. A *relay* payload crosses a network to a device that may not be +/// an Apple one at all, so it is JSON. Both are lossless for the value types the payload admits, +/// which is the whole reason the payload admits so few. +/// +/// This class is public so that a `StateRelay` written by an application can use the same wire +/// format the built-in one does, and so tests can assert on it. +public final class StateCodec { + private static final String KEY_ROUTES = "routes"; + private static final String KEY_PAYLOAD = "payload"; + private static final String KEY_DEVICE = "device"; + private static final String KEY_TITLE = "title"; + private static final String KEY_SEQUENCE = "seq"; + private static final String KEY_TIMESTAMP = "ts"; + + /// 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.getPayload())); + m.put(KEY_DEVICE, state.getDeviceId()); + if (state.getTitle() != null) { + m.put(KEY_TITLE, state.getTitle()); + } + // Written as strings rather than as numbers. Both survive a property list, but a JSON + // round trip through JSONParser reads every number back as a Double, and a millisecond + // timestamp is past the range a double represents exactly -- so the same two fields would + // come back changed on the relay path and unchanged on the continuation path. One + // encoding for both keeps a state comparable with itself however it arrived. + m.put(KEY_SEQUENCE, Long.toString(state.getSequence())); + m.put(KEY_TIMESTAMP, Long.toString(state.getTimestamp())); + return m; + } + + /// Rebuilds a state from the map form. Unknown keys are ignored, so a newer build of the app + /// on another device can add fields without breaking this one. + /// + /// #### Parameters + /// + /// - `m`: the map, or null + /// + /// #### Returns + /// + /// the state, or null when the map is null or carries nothing recognizable + public static AppState fromMap(Map m) { + if (m == null) { + return null; + } + // 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; + } + 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); + } + } + state.setRoutes(paths); + } + 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(), decode(entry.getValue())); + } + } + // Not validated on the way in. This map came from another device, and refusing it + // would turn that device's mistake into an exception on this one at a moment the user + // cannot connect to anything they did. + state.setPayloadUnchecked(copy); + } + Object device = m.get(KEY_DEVICE); + if (device instanceof String) { + state.setDeviceId((String) device); + } + Object title = m.get(KEY_TITLE); + if (title instanceof String) { + state.setTitle((String) title); + } + state.setSequence(asLong(m.get(KEY_SEQUENCE))); + state.setTimestamp(asLong(m.get(KEY_TIMESTAMP))); + return state; + } + + /// Renders a state as JSON, for a relay. + /// + /// #### Parameters + /// + /// - `state`: the state, must not be null + /// + /// #### Returns + /// + /// the JSON document + public static String toJson(AppState state) { + return JSONWriter.toJson(toMap(state)); + } + + /// Parses the JSON form. + /// + /// #### Parameters + /// + /// - `json`: the document, or null + /// + /// #### Returns + /// + /// the state, or null when the document is null, empty or not an object + /// + /// #### Throws + /// + /// - `java.io.IOException`: when the document is malformed + public static AppState fromJson(String json) throws IOException { + if (json == null || json.trim().length() == 0) { + return null; + } + return fromMap(JSONParser.parseJSON(json)); + } + + /// Throws when any value in the map could not survive being written to a property list, sent + /// as JSON and read back by another build of the app on another device. + /// + /// The admitted types are `String`, `Integer`, `Long`, `Double`, `Boolean`, and `List` and + /// `Map` of those. `Map` keys must be strings, because neither destination format has any + /// other kind of key. + /// + /// #### Parameters + /// + /// - `payload`: the payload, or null + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: naming the path to the first offending value + public static void requireRepresentable(Map payload) { + if (payload == null) { + return; + } + for (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. + /// + /// An untagged value is passed through as-is rather than refused: it is what a hand-written + /// endpoint, or a device running a build older than the tagging, produces -- and a payload + /// that is merely untyped is more useful than no payload at all. + 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') { + return Boolean.valueOf(body); + } + } 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) { + 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 true; + } + } + return false; + } + + 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..1f0e3cd4400 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityBridge.java @@ -0,0 +1,109 @@ +/* + * 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. Called once during initialization, before any other + /// method on this bridge; ports must retain it and may call it from any thread. + /// + /// #### Parameters + /// + /// - `callback`: the seam, never null + void setCallback(ContinuityCallback callback); +} diff --git a/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java b/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java new file mode 100644 index 00000000000..0178400cc7d --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/spi/ContinuityCallback.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity.spi; + +import java.util.Map; + +/// The framework's inbound seam, handed to every `ContinuityBridge` during initialization. Ports +/// call it when the platform delivers something; they never dispatch to application code +/// themselves. +/// +/// Both methods may be called from any thread, including before the application has a form on +/// screen: on Apple platforms a continuation can cold-launch the app, and the operating system +/// hands it over while the virtual machine is still starting. The framework holds such a delivery +/// until there is somewhere to show it, so implementations must not try to do that themselves. +public interface ContinuityCallback { + /// A continuation arrived from one of the user's other devices. + /// + /// #### Parameters + /// + /// - `activityType`: the reverse-DNS type it arrived under + /// - `userInfo`: the payload, as strings, numbers, booleans, lists and maps of those + /// + /// #### Returns + /// + /// true when the application claimed it, so the port can answer the platform honestly rather + /// than swallowing activities this app never published + boolean continuationReceived(String activityType, Map userInfo); + + /// The synced store changed underneath the app, because another of the user's devices wrote + /// to it. Carries no values: the framework re-reads what it needs. + void syncedStoreChanged(); +} diff --git a/CodenameOne/src/com/codename1/continuity/spi/package-info.java b/CodenameOne/src/com/codename1/continuity/spi/package-info.java new file mode 100644 index 00000000000..9e64f0fb681 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/spi/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The platform seam of the continuity framework. Ports implement `ContinuityBridge` and the +/// framework hands each one a `ContinuityCallback` to deliver through. +/// +/// Application code uses `com.codename1.continuity.Continuity` and never these types. +package com.codename1.continuity.spi; diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java new file mode 100644 index 00000000000..48a149ee04e --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStore.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity.sync; + +import com.codename1.continuity.Continuity; +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.io.Log; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.List; + +/// A small key/value store the platform carries between the devices one person signed in to, +/// without them ever being in the same room. +/// +/// This is the slow, patient half of continuity. `com.codename1.continuity.Continuity` hands the +/// current activity to a device that is *here, now*; this keeps a handful of durable settings -- +/// which theme, which sort order, which tutorial they already dismissed, the id of the document +/// they are working through -- in step across everything they own. +/// +/// ```java +/// SyncedStore.put("sortOrder", "byDate"); +/// String order = SyncedStore.get("sortOrder", "byName"); +/// ``` +/// +/// #### What it is not +/// +/// Not storage. Not a database, not a cache, and not a place for anything the app cannot cheerfully +/// do without: the platform decides when to sync, the user can turn the whole mechanism off, and a +/// device that has never been online has an empty store. Treat every read as "the value, or the +/// default" -- which is why there is no read without a default. +/// +/// Not secret. The contents leave the device and are held by the platform on the user's behalf. +/// Credentials belong in `com.codename1.security.SecureStorage`. +/// +/// Not large. The platform imposes a total size and a key count, both small; `put` reports a +/// failure to write rather than pretending it stored something. +/// +/// #### What it costs +/// +/// Referencing this package is what makes an iOS build ask for the entitlement that gives the app +/// a synced store, which in turn requires the capability to be enabled on the App ID. That is why +/// it is a package of its own: an app that wants continuation to a nearby device and nothing else +/// should not have to arrange an entitlement to get it. Where the platform has no such store -- +/// Android, desktop, the browser -- `isSupported()` is false and every call here is an inert +/// no-op, so the sensible shape is a synced value with a local default behind it. +public final class SyncedStore { + private static final List listeners = new ArrayList(); + + /// Guards `listeners`. Registration happens on whatever thread the application chooses and the + /// notification runs on the EDT, so every read and write of the list takes this. + private static final Object LISTENER_LOCK = new Object(); + + 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 + public static boolean put(String key, String value) { + requireKey(key); + if (value == null) { + throw new IllegalArgumentException("A synced store value cannot be null. Use " + + "SyncedStore.remove(\"" + key + "\") to delete the key."); + } + ContinuityBridge b = bridge(); + if (b == null) { + return false; + } + try { + if (!b.isSyncedStoreSupported()) { + return false; + } + 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 { + if (!b.isSyncedStoreSupported()) { + return def; + } + String value = b.syncedStoreGet(key); + return value == null ? def : value; + } catch (Throwable t) { + Log.e(t); + return def; + } + } + + /// Deletes a key. Deleting an absent key does nothing. + /// + /// #### Parameters + /// + /// - `key`: the key, must not be null or empty + public static void remove(String key) { + requireKey(key); + ContinuityBridge b = bridge(); + if (b == null) { + return; + } + try { + if (b.isSyncedStoreSupported()) { + b.syncedStoreRemove(key); + } + } catch (Throwable t) { + Log.e(t); + } + } + + /// Every key currently in the store, in no particular order. + /// + /// #### Returns + /// + /// the keys, never null and empty when the store is unavailable + public static String[] keys() { + ContinuityBridge b = bridge(); + if (b == null) { + return new String[0]; + } + try { + if (!b.isSyncedStoreSupported()) { + return new String[0]; + } + String[] k = b.syncedStoreKeys(); + return k == null ? new String[0] : k; + } catch (Throwable t) { + Log.e(t); + return new String[0]; + } + } + + /// Registers a listener for changes made on the user's other devices. + /// + /// #### Parameters + /// + /// - `l`: the listener + public static void addChangeListener(SyncedStoreListener l) { + synchronized (LISTENER_LOCK) { + // Guarded, because the registration API carries no EDT-only contract: an application + // registering from a worker raced the notification path's check and copy on the EDT, + // so a new listener could be missed, a removed one still called, or the snapshot + // taken mid-mutation. The same fix Continuity's own registry needed -- and this one + // is its sibling, which is exactly why it was missed the first time. + 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) { + synchronized (LISTENER_LOCK) { + 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() { + synchronized (LISTENER_LOCK) { + if (listeners.isEmpty()) { + return; + } + } + if (!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + // Copied before iterating: a listener that reacts to a change by unregistering + // itself is ordinary, and would otherwise mutate the list being walked. + List snapshot; + synchronized (LISTENER_LOCK) { + 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() { + synchronized (LISTENER_LOCK) { + listeners.clear(); + } + } +} diff --git a/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java b/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java new file mode 100644 index 00000000000..d35695975bf --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/sync/SyncedStoreListener.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity.sync; + +/// Notified when the synced store changed because another of the user's devices wrote to it. +/// +/// Called on the event dispatch thread. It carries no values -- read what you need with +/// `SyncedStore.get(String, String)`, because several keys can change together and the platform +/// does not always say which. +public interface SyncedStoreListener { + /// The store changed on another device. + void storeChanged(); +} diff --git a/CodenameOne/src/com/codename1/continuity/sync/package-info.java b/CodenameOne/src/com/codename1/continuity/sync/package-info.java new file mode 100644 index 00000000000..0bbac6f63c6 --- /dev/null +++ b/CodenameOne/src/com/codename1/continuity/sync/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// A small key/value store the platform carries between the devices one person is signed in to, +/// for the handful of durable choices that should follow them everywhere. +/// +/// Start at `SyncedStore`. This is a package of its own, rather than part of +/// `com.codename1.continuity`, because referencing it is what makes an iOS build ask for the +/// entitlement that grants a synced store -- and an app that only wants to hand work to the device +/// in the user's other hand should not have to arrange one. +package com.codename1.continuity.sync; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 3db0273961c..dc18c153587 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -6450,6 +6450,20 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return null; } + /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the + /// user's current activity to their other devices and to reach the platform's synced key/value + /// store. Ports supporting either capability override this; the base implementation returns + /// null, which leaves saving and restoring state on this device working -- that half is pure + /// `com.codename1.io.Storage` -- and makes every cross-device capability report itself + /// unsupported. + /// + /// #### Returns + /// + /// the continuity bridge, or null when unsupported + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return null; + } + /// Returns the platform bridge used by the `com.codename1.intents` API to expose the /// application's capabilities to the system -- assistant intents, app shortcuts and device /// search. Ports supporting intents override this; the base implementation returns null, which diff --git a/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java new file mode 100644 index 00000000000..c184c3480fc --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/continuity/LocalContinuityBridge.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.continuity; + +import com.codename1.continuity.spi.ContinuityBridge; +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.Log; +import com.codename1.io.Preferences; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// A simulated continuity platform, used by the simulator, the desktop builds and the unit tests. +/// +/// A simulation rather than nothing, for the reason the call and nearby bridges carry one: almost +/// everything an app does with continuity -- deciding what belongs in the payload, prompting before +/// a jump, rebuilding a screen from a route -- has nothing to do with the operating system that +/// carries the state, and a port that reported nothing would make all of it testable only on a +/// pair of phones. +/// +/// It keeps the last published activity in memory so the Simulate menu can show what the app is +/// offering, and it can hand that activity straight back through `simulateArrival()` -- which is +/// what "continue this on another device" is, minus the second device. The synced store is real +/// within one machine: it is backed by `com.codename1.io.Preferences`, so it survives a simulator +/// restart the way the platform store survives a device one. +public class LocalContinuityBridge implements ContinuityBridge { + /// Prefix for the simulated synced store's keys inside `Preferences`. + private static final String PREFIX = "CN1$SyncedStore$"; + + /// The list of keys, kept beside them because `Preferences` cannot be enumerated. + private static final String INDEX = "CN1$SyncedStoreKeys"; + + /// Guards the four fields below. + /// + /// They are written on the Codename One EDT -- setCallback from enable(), the published + /// activity from a checkpoint -- and read on the AWT event thread, because the simulator's + /// "Simulate ->" menu calls simulateArrival() and simulateStoreChange() from there. Without + /// this there is no happens-before between the two, so the menu could read a half-published + /// activity or miss the callback entirely, and the item would report "nothing to deliver" for + /// a state the application had just checkpointed. Nothing calls out to application code while + /// holding it. + private final Object lock = new Object(); + + /// Serializes the read-modify-write of the simulated store's key index. + /// + /// Static, because the index lives in Preferences rather than in this object: two bridges -- + /// the simulator swaps them -- write the same underlying list, so an instance lock would not + /// actually serialize anything. + private static final Object INDEX_LOCK = new Object(); + + private ContinuityCallback callback; + private String publishedType; + private String publishedTitle; + private Map publishedInfo; + + @Override + public void setCallback(ContinuityCallback c) { + synchronized (lock) { + callback = c; + } + } + + @Override + public boolean isContinuationSupported() { + return true; + } + + @Override + public void publishContinuation(String activityType, String title, + Map userInfo) { + Map copy = userInfo == null + ? null : new HashMap(userInfo); + synchronized (lock) { + // All three together: the menu reads the type and the payload as a pair, and setting + // them separately let it see a new type beside the previous payload. + publishedType = activityType; + publishedTitle = title; + publishedInfo = copy; + } + } + + @Override + public void clearContinuation() { + synchronized (lock) { + publishedType = null; + publishedTitle = null; + publishedInfo = null; + } + } + + /// The activity type currently advertised, or null when nothing is. + /// + /// #### Returns + /// + /// the type + public String getPublishedType() { + synchronized (lock) { + return publishedType; + } + } + + /// The label currently advertised, or null. + /// + /// #### Returns + /// + /// the label + public String getPublishedTitle() { + synchronized (lock) { + return publishedTitle; + } + } + + /// The payload currently advertised, or null when nothing is. + /// + /// #### Returns + /// + /// a copy of the payload + public Map getPublishedInfo() { + synchronized (lock) { + // The null check and the copy under ONE hold: a clear landing between them turned the + // copy into new HashMap(null), which throws. The two accessors beside this one were + // guarded and this was missed -- the same enumeration slip that keeps costing here. + return publishedInfo == null ? null : new HashMap(publishedInfo); + } + } + + /// Delivers the currently advertised activity back to the app as though it had arrived from + /// another device, which is what the Simulate menu's "continue on this device" does. + /// + /// The device id inside the payload is rewritten first. Without that the framework would + /// recognize the state as this device's own echo and correctly ignore it, and the menu item + /// would appear to do nothing. + /// + /// #### Returns + /// + /// true when there was an activity to deliver and the app claimed it + public boolean simulateArrival() { + String type; + Map copy; + synchronized (lock) { + if (publishedType == null || publishedInfo == null) { + return false; + } + // Read as a pair and copied under the lock, so a checkpoint landing mid-read cannot + // hand the menu one activity's type with another's payload. + type = publishedType; + copy = new HashMap(publishedInfo); + } + copy.put("device", "simulated-device"); + return simulateArrival(type, 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; + synchronized (lock) { + 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) { + synchronized (INDEX_LOCK) { + // The VALUE write is inside the lock too. Serializing only the index left the two + // halves able to interleave with remove(): the delete could land between this write + // and the index update, leaving a listed key with no value -- or this could report + // success while the concurrent remove stripped its index entry, so keys() omitted a + // value that is really stored. The store and its index have to move together or they + // do not describe the same thing. + Preferences.set(PREFIX + key, value); + // Read, modify and write the key index under ONE hold. Two concurrent put()s each + // read the same index, each added their own key, and the second write erased the + // first: both values stayed readable directly, while keys() omitted one of them for + // good -- so enumeration and clearTheSyncedStore() disagreed with the store itself. + List keys = indexKeys(); + if (!keys.contains(key)) { + keys.add(key); + writeIndex(keys); + } + // Read back rather than assume, so the simulation answers the same question the + // device does: is the value there now? Under the lock, so the answer cannot be + // invalidated by a remove() between the write and the read. + return value.equals(Preferences.get(PREFIX + key, null)); + } + } + + @Override + public String syncedStoreGet(String key) { + return Preferences.get(PREFIX + key, null); + } + + @Override + public void syncedStoreRemove(String key) { + synchronized (INDEX_LOCK) { + Preferences.delete(PREFIX + key); + List keys = indexKeys(); + if (keys.remove(key)) { + writeIndex(keys); + } + } + } + + @Override + public String[] syncedStoreKeys() { + synchronized (INDEX_LOCK) { + // Under the same hold the writers take, so an enumeration cannot read the index + // halfway through somebody's update. + List keys = indexKeys(); + return keys.toArray(new String[keys.size()]); + } + } + + /// Reports a change made "on another device", which the Simulate menu uses to exercise an + /// app's `SyncedStoreListener` without a second machine. + public void simulateStoreChange() { + ContinuityCallback c; + synchronized (lock) { + c = callback; + } + if (c == null) { + return; + } + try { + c.syncedStoreChanged(); + } catch (Throwable t) { + Log.e(t); + } + } + + private List indexKeys() { + List keys = new ArrayList(); + String raw = Preferences.get(INDEX, ""); + if (raw == null || raw.length() == 0) { + return keys; + } + // Newline separated 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(); + } + + private void writeIndex(List keys) { + StringBuilder sb = new StringBuilder(); + for (String key : keys) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(escapeIndexEntry(key)); + } + Preferences.set(INDEX, sb.toString()); + } +} diff --git a/CodenameOne/src/com/codename1/impl/continuity/package-info.java b/CodenameOne/src/com/codename1/impl/continuity/package-info.java new file mode 100644 index 00000000000..d77c8207f77 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/continuity/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// The simulated continuity platform behind the simulator, the desktop builds and the unit tests. +/// +/// Internal. Application code uses `com.codename1.continuity`. +package com.codename1.impl.continuity; diff --git a/CodenameOne/src/com/codename1/router/Navigation.java b/CodenameOne/src/com/codename1/router/Navigation.java index 0a582da09ef..9413fc9c2ab 100644 --- a/CodenameOne/src/com/codename1/router/Navigation.java +++ b/CodenameOne/src/com/codename1/router/Navigation.java @@ -105,6 +105,7 @@ public static boolean navigate(String path) { } stack.add(new NavigationEntry(path, f)); f.show(); + stackChanged(); return true; } @@ -119,6 +120,7 @@ public static boolean back() { stack.remove(stack.size() - 1); NavigationEntry now = stack.get(stack.size() - 1); now.getForm().showBack(); + stackChanged(); return true; } @@ -162,9 +164,77 @@ public static boolean popTo(NavigationEntry entry) { stack.remove(stack.size() - 1); } entry.getForm().showBack(); + stackChanged(); return true; } + /// Rebuilds the stack from a list of paths, showing only the last one. + /// + /// This is how `com.codename1.continuity.Continuity` puts the user back where they were: the + /// saved state is a list of paths, and every one of them has to become a stack frame or + /// `back()` would land on a screen that was never built. Replaying them with `navigate` would + /// work and would also flash every intermediate screen past the user with a transition each, + /// so the frames are built silently and only the top one is shown. + /// + /// Paths that no longer match a route are skipped rather than failing the restore. A rebuilt + /// app legitimately drops routes, and refusing to restore anything because one deep frame went + /// away would lose the whole session over a screen the user was not on. + /// + /// Replaces whatever was on the stack. Must be called on the EDT. + /// + /// #### Parameters + /// + /// - `paths`: the paths, oldest first + /// + /// #### Returns + /// + /// true when at least one frame was rebuilt and shown + public static boolean restoreStack(List paths) { + RouteDispatcher d = dispatcher; + if (d == null || paths == null || paths.isEmpty()) { + return false; + } + List rebuilt = new ArrayList(); + for (String path : paths) { + if (path == null || path.length() == 0) { + continue; + } + Form f; + try { + f = d.dispatch(path); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + continue; + } + if (f != null) { + rebuilt.add(new NavigationEntry(path, f)); + } + } + if (rebuilt.isEmpty()) { + return false; + } + stack.clear(); + stack.addAll(rebuilt); + // show(), not showBack(): the user is arriving, not going back, and showBack would run + // the reverse transition into a screen they have not seen yet. + rebuilt.get(rebuilt.size() - 1).getForm().show(); + stackChanged(); + return true; + } + + /// Tells the continuity framework that the stack moved, so it can checkpoint. + /// + /// A direct call rather than a listener: `Continuity.routeStackChanged()` returns immediately + /// unless an application actually enabled continuity, and a listener registry here would be + /// public API earned by one internal caller. + private static void stackChanged() { + try { + com.codename1.continuity.Continuity.routeStackChanged(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + // ------------------------------------------------------------------------ // Internal: framework-side entry point invoked by Display when the // platform delivers a deep link through `AppArg`. diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 59fdef98def..3fc8602f960 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -5873,6 +5873,18 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return impl.getDocumentProviderBridge(); } + /// Returns the platform bridge used by the `com.codename1.continuity` API to advertise the + /// user's current activity to their other devices and to reach the platform's synced key/value + /// store, or null when unsupported on this port. Internal -- application code uses the + /// `com.codename1.continuity` API rather than this bridge directly. + /// + /// #### Returns + /// + /// the continuity bridge, or null + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return impl.getContinuityBridge(); + } + /// Returns the platform bridge used by the `com.codename1.intents` API to expose the /// application's capabilities to the system, or null when unsupported on this port. Internal -- /// application code uses the `com.codename1.intents` API rather than this bridge directly. diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 21660be5870..082f97e400f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -6485,6 +6485,26 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return documentProviderBridge; } + private com.codename1.continuity.spi.ContinuityBridge continuityBridge; + + /// Returns the continuity bridge, which on Android exists for one job: + /// flushing the state checkpoint when the platform says the process may + /// be killed. Neither cross-device capability exists here and both report + /// themselves unsupported. + /// + /// Synchronized for the reason the intent bridge is: two callers arriving + /// together would each construct one, and each construction registers a + /// lifecycle listener -- so the loser's listener would stay registered and + /// the app would checkpoint twice on every save. + @Override + public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + if (continuityBridge == null) { + continuityBridge = + new com.codename1.impl.android.continuity.AndroidContinuityBridge(); + } + return continuityBridge; + } + private com.codename1.intents.spi.IntentBridge intentBridge; @Override diff --git a/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.java new file mode 100644 index 00000000000..20e29091cdf --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/continuity/AndroidContinuityBridge.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.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 { + Continuity.checkpoint(); + } 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. + try { + Continuity.pollRelay(); + } catch (Throwable t) { + Log.e(t); + } + } + + @Override + public void onPause() { + } + + @Override + public void onDestroy() { + } + + @Override + public void onSaveInstanceState(Bundle b) { + try { + if (!Continuity.isCheckpointPending()) { + // The ordinary case, and the reason this is asked first. The framework writes + // through as the user navigates, so by the time Android says it may kill the + // process there is usually nothing owed -- and answering that here costs no + // thread hop at all. + return; + } + // 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 both from here raced the running + // application and could capture a half-changed screen -- or throw, and lose the + // payload with nothing said. + // + // Waiting blocks Android's main thread, which is why it is behind the check + // above: it is paid only when there is genuinely something to save, not on every + // suspend. + 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..14cad9821ef --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/ContinuitySimulatorHooks.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.StateCodec; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.impl.continuity.LocalContinuityBridge; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Simulator hooks that script state restoration and continuity. +/// +/// Registered in `META-INF/codenameone/simulator-hooks.properties`. The labelled ones become a +/// Simulate menu; every one is callable from a test with `CN.execute("continuity:itemN")`. +/// +/// #### These reproduce traps, not happy paths +/// +/// Restoring cleanly is what the app does anyway. What is worth a click is the state that arrives +/// while the user is midway through something, the one that arrives before there is a form to show +/// it on, the one from a build whose routes have since been renamed, and the synced store changing +/// underneath a screen that already read it. Each is a real device behaviour an app written +/// against the cheerful path gets wrong, and each is otherwise reachable only by arranging two +/// devices. +public final class ContinuitySimulatorHooks { + + private ContinuitySimulatorHooks() { + } + + private static LocalContinuityBridge bridge() { + return JavaSEPort.getSimulatedContinuity(); + } + + /// Hands what this app is currently advertising straight back to it, as though the user had + /// picked it up on a second device. + /// + /// This is the whole feature in one click: publish, then continue. It does nothing when the + /// app has not taken a checkpoint yet, which is itself the answer to "why is nothing being + /// offered". + public static void continueHere() { + bridge().simulateArrival(); + } + + /// Delivers a state that names a route the build no longer has. + /// + /// An app is rebuilt and a screen goes away, and the states already sitting on the user's + /// other devices still name it. The restore has to survive that with the frames it can still + /// build rather than losing the session over one screen the user was not even on. + public static void continueWithAStaleRoute() { + AppState state = new AppState(); + List routes = new ArrayList(); + routes.add("/a-route-this-build-no-longer-has"); + state.setRoutes(routes) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis()) + .setTitle("From a older build"); + deliver(state); + } + + /// Delivers a state whose payload is present but whose route stack is empty, which is what an + /// app that does not use `@Route` produces. + /// + /// The framework restores nothing on its own here: the payload goes to the `StateProvider` and + /// showing a form is the app's job. An app that assumed `restore()` always shows something + /// finds out here rather than on a customer's phone. + public static void continuePayloadOnly() { + AppState state = new AppState(); + Map payload = new HashMap(); + payload.put("simulated", Boolean.TRUE); + state.setPayload(payload) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis()) + .setTitle("Payload only"); + deliver(state); + } + + /// Delivers a state that is a day old. + /// + /// Exercises `Continuity.setMaxAge(long)` and, more usefully, the listener that has to decide + /// whether moving the user somewhere they were yesterday is a courtesy or an ambush. + public static void continueSomethingStale() { + AppState state = new AppState(); + state.setRoutes(currentRoutes()) + .setDeviceId("simulated-device") + .setSequence(System.currentTimeMillis()) + .setTimestamp(System.currentTimeMillis() - 86400000L) + .setTitle("From yesterday"); + deliver(state); + } + + /// Reports that the synced store changed on another device, without changing a value. + /// + /// The notification carries no values on any platform, so an app that assumed it did -- and + /// only re-reads the key it thinks changed -- reads a stale one here. + public static void changeTheSyncedStore() { + bridge().simulateStoreChange(); + } + + /// Makes the synced store report itself unsupported, which is what every non-Apple platform + /// does. + /// + /// An app that put a required setting in there and never checked `isSupported()` loses it + /// here, silently, exactly as it would on Android. + public static void makeTheSyncedStoreUnsupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() { + @Override + public boolean isSyncedStoreSupported() { + return false; + } + }); + } + + /// Makes continuation report itself unsupported, which is what every non-Apple platform does. + public static void makeContinuationUnsupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge() { + @Override + public boolean isContinuationSupported() { + return false; + } + }); + } + + /// Restores the fully capable simulated platform. + public static void makeEverythingSupported() { + JavaSEPort.setSimulatedContinuity(new LocalContinuityBridge()); + } + + /// Takes a checkpoint now, so the menu items above have something to hand back. + public static void checkpointNow() { + Continuity.checkpoint(); + } + + /// Forgets the stored state, the way a logout does. + public static void clearStoredState() { + Continuity.clear(); + } + + /// Empties the simulated synced store. + public static void clearTheSyncedStore() { + String[] keys = SyncedStore.keys(); + for (int i = 0; i < keys.length; i++) { + SyncedStore.remove(keys[i]); + } + } + + private static List currentRoutes() { + List routes = new ArrayList(); + List stack = + com.codename1.router.Navigation.getStack(); + for (int i = 0; i < stack.size(); i++) { + routes.add(stack.get(i).getPath()); + } + if (routes.isEmpty()) { + routes.add("/"); + } + return routes; + } + + private static void deliver(AppState state) { + // Through the bridge rather than through Continuity directly, so the item exercises the + // same inbound path a device uses -- including the activity-type check, which is where a + // mismatch between the build's declared type and the framework's would show up. + bridge().simulateArrival(Continuity.getActivityType(), StateCodec.toMap(state)); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 2540198e600..7ad82ad08fb 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16590,6 +16590,8 @@ public com.codename1.health.Health getHealth() { private static com.codename1.impl.nearby.LocalNearbyBridge nearbyBridge; + private static com.codename1.impl.continuity.LocalContinuityBridge continuityBridge; + private static com.codename1.impl.call.LocalCallBridge callBridge; private static com.codename1.impl.vpn.LocalVpnBridge vpnBridge; @@ -16668,6 +16670,55 @@ public static com.codename1.impl.call.LocalCallBridge getSimulatedCalls() { } } + /// The continuity bridge for the simulator and desktop builds. + /// + /// A simulated one rather than none, for the reason + /// [#getCallBridge()] carries one: an app's continuity work is deciding + /// what belongs in the payload, prompting before a jump and rebuilding a + /// screen from a route, none of which has anything to do with the + /// operating system that carries the state. A port that reported nothing + /// would make every bit of it testable only on a pair of phones. + /// + /// The simulated synced store is backed by `Preferences`, so it survives + /// a simulator restart the way the platform store survives a device one. + @Override + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + return getSimulatedContinuity(); + } + + /// The simulated continuity platform, for the Simulate menu to script. + /// + /// Static and class-guarded for the reason the call bridge is: it holds + /// the advertised activity and the framework's callback, and two threads + /// racing this getter would each get their own -- an activity published + /// through one would be invisible to the menu item that hands it back. + public static com.codename1.impl.continuity.LocalContinuityBridge getSimulatedContinuity() { + synchronized (JavaSEPort.class) { + if (continuityBridge == null) { + continuityBridge = new com.codename1.impl.continuity.LocalContinuityBridge(); + } + return continuityBridge; + } + } + + /// Replaces the simulated continuity platform, so the Simulate menu can + /// swap in one that reports a capability as missing. + /// + /// The framework's inbound seam is re-installed on the replacement: + /// without that the new bridge would have no callback, and every menu + /// item that delivers a continuation would silently do nothing. + /// + /// #### Parameters + /// + /// - `b`: the replacement, never null + public static void setSimulatedContinuity( + com.codename1.impl.continuity.LocalContinuityBridge b) { + synchronized (JavaSEPort.class) { + continuityBridge = b; + } + com.codename1.continuity.Continuity.refreshBridge(); + } + /// The VPN bridge for the simulator and desktop builds: a simulated /// configuration store that tunnels nothing. /// diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index ac569f96dcc..9f2e81c4ef7 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -110,6 +110,13 @@ // where this class is not translated and the header does not exist. #import "com_codename1_impl_ios_IOSIntentCallbacks.h" #endif +#ifdef CN1_USE_CONTINUITY +// Same reasoning as the intents header above, and the same guard: the continuity branch below +// calls a translated entry point, and an implicit declaration is a hard error on some slices and +// a wrong-registers call on the rest. CodenameOne_GLViewController.h undefines +// CN1_USE_CONTINUITY for watchOS and tvOS, where this class is not translated. +#import "com_codename1_impl_ios_IOSContinuityCallbacks.h" +#endif // A signal handler to handle bad accesses. This will throw NPEs that we can catch // rather than crashing the app. @@ -279,6 +286,47 @@ - (BOOL)cn1ContinueUserActivity:(NSUserActivity *)userActivity #endif return YES; } +#ifdef CN1_USE_CONTINUITY + // Continuity is matched BEFORE intents, and the order is load-bearing. The intents block + // below ends in a general branch that hands any remaining activity type to Java and returns + // Java's answer -- and Intents.dispatchUserActivity correctly answers NO for a type it never + // declared. An app using both would therefore have its own continuation asked about by the + // wrong framework, told no, and dropped. Matching here first keeps each framework answering + // only for the types it published. + // + // Placed after the browsing-web branch rather than before it for the reason that branch is + // first: Universal Link behaviour must be bit-identical whether or not continuity is in play. + if (userActivity != nil && [userActivity.activityType hasSuffix:@".continuity"]) { + NSString *payload = nil; + if (userActivity.userInfo != nil + && [NSJSONSerialization isValidJSONObject:userActivity.userInfo]) { + NSData *data = [NSJSONSerialization dataWithJSONObject:userActivity.userInfo + options:0 error:nil]; + if (data != nil) { + // Autoreleased: the app target is manual-reference-counted and this method + // returns without a release, so every continuation would otherwise retain its + // serialized payload for the life of the process. + payload = [[[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding] autorelease]; + } + } + JAVA_OBJECT jtype = fromNSString(CN1_THREAD_GET_STATE_PASS_ARG userActivity.activityType); + JAVA_OBJECT jpayload = payload == nil ? JAVA_NULL + : fromNSString(CN1_THREAD_GET_STATE_PASS_ARG payload); +#ifdef NEW_CODENAME_ONE_VM + JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String_R_boolean(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload); +#else + JAVA_BOOLEAN claimed = com_codename1_impl_ios_IOSContinuityCallbacks_nativeContinuation___java_lang_String_java_lang_String(CN1_THREAD_GET_STATE_PASS_ARG jtype, jpayload); +#endif + if (claimed == JAVA_TRUE) { + return YES; + } + // Not claimed: the suffix matched but the framework did not recognize the type as its + // own, which is what a third-party activity whose type happens to end the same way looks + // like. Falls through rather than returning NO, so the intents branch below still gets + // its chance at it. + } +#endif #ifdef CN1_USE_INTENTS // Everything below is compiled only for an app that references // com.codename1.intents, so a build without it produces exactly the function above. @@ -523,13 +571,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 +750,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..10a112284a3 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -222,6 +222,16 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // FileProvider symbols at all. //#define CN1_USE_DOCUMENTS +// CN1_USE_CONTINUITY gates state restoration and cross-device continuity: the IOSNative +// continuity* implementations (NSUserActivity for handing work to a nearby device, +// NSUbiquitousKeyValueStore for the synced store) plus the continuity branch in +// CodenameOne_GLAppDelegate. IPhoneBuilder uncomments this only when the classpath scanner saw +// com.codename1.continuity.*, so an app that restores nothing links neither. +// +// Note what this define does NOT gate: saving and restoring state on this device is pure Java +// over com.codename1.io.Storage and works in every build, define or no define. +//#define CN1_USE_CONTINUITY + // CN1_APP_INTENTS_DECLARED is the narrower question: did the build actually generate App Intent // declarations? CN1_USE_INTENTS only says the app references the package, and an app can use // indexing and donation while switching declarations off with ios.intents.appIntents=false. @@ -232,9 +242,13 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, //#define CN1_APP_INTENTS_DECLARED // Core Spotlight and App Intents are unavailable on watchOS / tvOS; undo the defines there. +// Continuity goes with them: NSUserActivity handoff has no watchOS or tvOS counterpart, and +// NSUbiquitousKeyValueStore is unavailable on both. The Java half is unaffected -- a watch app +// still saves and restores its own state, which is the half that needs no native support. #if TARGET_OS_WATCH || TARGET_OS_TV #undef CN1_USE_INTENTS #undef CN1_APP_INTENTS_DECLARED +#undef CN1_USE_CONTINUITY #endif // CN1_USE_WATCHCONNECTIVITY gates the phone-to-watch link (CN1WatchConnectivity.{h,m} + the diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 17165743dec..17bcb5a6f16 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -20016,6 +20016,333 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole return com_codename1_impl_ios_IOSNative_intentsIndexingSupported__(CN1_THREAD_STATE_PASS_ARG instanceObject); } + +// --- State restoration and continuity (com.codename1.continuity) ------------- +// +// Two unrelated Apple mechanisms, gated together on CN1_USE_CONTINUITY but answered separately +// to Java, because they cost different things. NSUserActivity with eligibleForHandoff carries +// what the user is doing to a device they are holding and needs no entitlement at all; the +// NSUbiquitousKeyValueStore below carries a few durable values to every device on the account +// and needs one that has to be granted on the App ID. An app wanting only the first must not be +// made to arrange the second, which is why com.codename1.continuity.sync is a separate package +// and why the store reports its own availability rather than assuming it. +// +// What is NOT here: saving and restoring state on this device. That is pure Java over +// com.codename1.io.Storage and works in every build, with or without this define. + +#ifdef CN1_USE_CONTINUITY + +/// The advertised activity, or nil. A single slot rather than the bounded ring the intent +/// donations use: a donation is a historical fact the system may keep offering, while this is +/// "what the user is doing right now" and there is only ever one of those. Publishing again +/// replaces it. +static NSUserActivity *cn1ContinuityActivity = nil; + +/// Retained so the observer can be reasoned about, though nothing ever removes it: the store's +/// external-change notification is wanted for the entire life of the process. +static id cn1ContinuityStoreObserver = nil; + +// The translated entry point the store observer below calls. Without this it is an implicit +// declaration, which C99 and every clang that enforces it reject outright -- and where a +// toolchain still accepts one, the invented prototype passes the thread state through whatever +// registers the default promotions choose. Same reasoning, and the same fix, as the +// IOSWearableCallbacks declarations further down this file. +extern JAVA_VOID com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__( + CODENAME_ONE_THREAD_STATE); + +static NSDictionary *cn1ContinuityParseJson(NSString *json) { + if (json == nil) { + return nil; + } + NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding]; + if (data == nil) { + return nil; + } + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSDictionary class]] ? (NSDictionary *)parsed : nil; +} + +/// Reduces a parsed JSON value to what a property list can hold, recursively. +/// +/// The intent donation path beside this one flattens to strings and numbers, which is all a +/// donation carries. A continuity payload is nested by construction -- an array of route paths +/// and a map of the application's own values -- so flattening it would deliver an activity with +/// the routes silently missing, which looks exactly like the feature not working. +/// +/// Anything with no property-list representation, NSNull included, is dropped rather than +/// substituted: the Java side validated the payload where the application produced it, so the +/// only values that can reach here are ones JSON introduced on its own. +static id cn1ContinuitySanitize(id value) { + if ([value isKindOfClass:[NSString class]] || [value isKindOfClass:[NSNumber class]]) { + return value; + } + if ([value isKindOfClass:[NSArray class]]) { + NSMutableArray *out = [NSMutableArray array]; + for (id item in (NSArray *)value) { + id safe = cn1ContinuitySanitize(item); + if (safe != nil) { + [out addObject:safe]; + } + } + return out; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + NSDictionary *dict = (NSDictionary *)value; + for (id key in dict) { + if (![key isKindOfClass:[NSString class]]) { + continue; + } + id safe = cn1ContinuitySanitize([dict objectForKey:key]); + if (safe != nil) { + [out setObject:safe forKey:key]; + } + } + return out; + } + return nil; +} + +/// The synced store, or nil when this build did not earn one. +/// +/// Resolved once and cached, because the answer cannot change while the process runs: it is a +/// property of how the app was signed. +/// +/// Three guards rather than one, and deliberately so. The entitlement is missing in the ordinary +/// case that an app references com.codename1.continuity.sync and the App ID never had iCloud +/// enabled, and what that produces has not been the same across releases of iOS -- a nil store, a +/// store whose synchronize answers NO, and a raised exception have all been reported. Guessing +/// which one this OS does would leave the app writing values into nothing on the others, and the +/// symptom of that is a setting that silently fails to follow the user. +static NSUbiquitousKeyValueStore *cn1ContinuityStore(void) { + static NSUbiquitousKeyValueStore *store = nil; + static 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 "is this store actually + // usable", and it answers NO for reasons that pass: an offline launch is the obvious one. A + // one-time initializer cached that NO for the life of the process, so an entitled app that + // happened to start without connectivity reported the synced store unsupported forever, with + // no observer, even once the network came back. Resolving again on the next call costs one + // synchronize; getting it permanently wrong costs the feature. + pthread_mutex_lock(&cn1ContinuityStoreLock); + if (store == nil) { + @try { + NSUbiquitousKeyValueStore *s = [NSUbiquitousKeyValueStore defaultStore]; + if (s != nil && [s synchronize]) { + store = [s retain]; + cn1ContinuityStoreObserver = [[[NSNotificationCenter defaultCenter] + addObserverForName:NSUbiquitousKeyValueStoreDidChangeExternallyNotification + object:s + queue:nil + usingBlock:^(NSNotification *note) { + com_codename1_impl_ios_IOSContinuityCallbacks_nativeSyncedStoreChanged__( + CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + }] retain]; + } + } @catch (NSException *e) { + store = nil; + } + } + 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) { + return cn1ContinuityStore() != nil ? JAVA_TRUE : 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) { + 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); + [store setString:v forKey:k]; + // synchronize is asked for rather than waited on -- the system syncs on its own schedule and + // this only moves it along -- but its answer is reported, because NO means the store is not + // usable and the application's write went nowhere. + BOOL synced = [store synchronize]; + // Read back as well. synchronize answers about the STORE; it says nothing about whether this + // particular value was accepted, and a store at its key or size limit drops the write without + // reporting anything. What can be established here is whether the value is retrievable now. + // Whether iCloud goes on to propagate it is not knowable from inside this call, and the Java + // documentation says only what this actually checks. + NSString *back = [store stringForKey:k]; + if (synced && 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..b1577822310 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityBridge.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.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; + } + } + + @Override + public boolean syncedStorePut(String key, String value) { + if (!isSyncedStoreSupported()) { + return false; + } + try { + return nativeInterface.continuitySyncedStorePut(key, value); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + @Override + public String syncedStoreGet(String key) { + if (!isSyncedStoreSupported()) { + return null; + } + try { + return nativeInterface.continuitySyncedStoreGet(key); + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + @Override + public void syncedStoreRemove(String key) { + if (!isSyncedStoreSupported()) { + return; + } + try { + nativeInterface.continuitySyncedStoreRemove(key); + } catch (Throwable t) { + Log.e(t); + } + } + + @Override + public String[] syncedStoreKeys() { + if (!isSyncedStoreSupported()) { + return new String[0]; + } + // The native call and the parse are what can fail, so they are what the handler covers. + // Everything below it is deliberately outside: the compiler inserts checked casts for the + // generic element type and for toArray's component type, and a failed cast does not throw + // on this virtual machine -- so a handler wrapped around one is a handler that cannot run + // here. See the ClassCastException note in CLAUDE.md. + Map parsed; + try { + String json = nativeInterface.continuitySyncedStoreKeys(); + if (json == null || json.length() == 0) { + return new String[0]; + } + parsed = JSONParser.parseJSON(json); + } catch (Throwable t) { + Log.e(t); + return new String[0]; + } + Object keys = parsed == null ? null : parsed.get("keys"); + if (!(keys instanceof List)) { + return new String[0]; + } + List read = (List) keys; + List out = new ArrayList(); + for (int i = 0; i < read.size(); i++) { + Object key = read.get(i); + if (key instanceof String) { + out.add((String) key); + } + } + return out.toArray(new String[out.size()]); + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.java new file mode 100644 index 00000000000..bf3be1a449d --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSContinuityCallbacks.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.codename1.impl.ios; + +import com.codename1.continuity.spi.ContinuityCallback; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; + +import java.util.HashMap; +import java.util.Map; + +/// Static callback surface the native continuity glue calls into. +/// +/// #### Why the static initializer calls everything once +/// +/// ParparVM's dead-code eliminator decides a Java method is reachable by scanning the `.m` sources +/// for its mangled symbol and by following Java call graphs. These methods have no Java caller, and +/// the failure mode when they are stripped is not a link error -- they translate to empty stubs and +/// the native dispatch silently does nothing, so the build is green and continuations never arrive. +/// The guarded self-call in the static initializer is what keeps them alive. +/// +/// The call must be unconditional. Wrapping it in an `if` the optimizer can prove false folds the +/// whole thing away and reintroduces the bug. +final class IOSContinuityCallbacks { + /// Guards `callback` and the pending arrival below. + /// + /// The platform hands a continuation over on a thread of its own -- on a cold launch from + /// `willConnectToSession`, before the EDT has run the application's init() -- while + /// setCallback runs on the EDT. Every field it protects is therefore written by one thread and + /// read by the other, and without it there was no happens-before between them at all: the + /// arrival a cold launch parked was not guaranteed to be visible to the thread that installs + /// the callback, and the take-and-clear in setCallback was not atomic with the store in + /// nativeContinuation. Either one silently drops the continuation, which is the single failure + /// this class exists to prevent. Nothing calls out to the framework while holding it. + private static final Object LOCK = new Object(); + + 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) { + String type; + String json; + synchronized (LOCK) { + // Installed and READ under one hold, but not yet cleared -- see below. A continuation + // landing between installing and reading was written into a slot this method had + // already passed, so it was dropped by the very call that exists to deliver it. + callback = c; + type = pendingType; + json = pendingJson; + } + if (c != null && type != null) { + // A continuation that cold-launched the app can reach this class before the + // application's init() has called Continuity.enable(), which is what installs the + // callback -- the scene delegate hands it over from willConnectToSession, which runs + // first. Delivered now instead of dropped, which is what the whole feature is for. + boolean claimed = false; + try { + claimed = c.continuationReceived(type, parse(json)); + } catch (Throwable t) { + Log.e(t); + } + if (claimed) { + synchronized (LOCK) { + // 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: initialization order alone silently lost the continuation. + // + // Only if it is still the same one. A newer arrival while the callback ran is + // the one worth keeping, and blindly nulling would discard it. + if (type.equals(pendingType) + && (json == null ? pendingJson == null : json.equals(pendingJson))) { + 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; + } + ContinuityCallback c; + synchronized (LOCK) { + c = callback; + } + if (c == null) { + // The framework has not been enabled yet. That is the ordinary cold-launch ordering + // rather than a mistake, so the activity is held for setCallback to deliver instead + // of being dropped. + // + // Claimed all the same. The delegate's answer decides whether the activity falls + // through to the intents branch beside it, and one this app is about to act on must + // not: an app using both frameworks would otherwise have its own continuation offered + // to the wrong one, which would correctly decline it, and the launch would land on the + // home screen. + // + // But only when it is OURS. The native side matches on the ".continuity" suffix, + // which an App Intent id may also end in -- and claiming one of those here skipped + // the intents branch for an activity this framework then discarded on delivery. + // + // 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, and a worse outcome than the bug being fixed. + // Asked OUTSIDE the lock: it reads the app's package name through the framework, and + // nothing slow or re-entrant may run under a lock the platform thread also takes. + String expected = expectedTypeOrNull(); + if (expected != null && !expected.equals(activityType)) { + return false; + } + synchronized (LOCK) { + // Re-read, because the framework may have been enabled while the question above + // was being answered. Parking an arrival for a setCallback that has already been + // and gone strands it until the next one -- and on a cold launch there is no next + // one. Delivering it directly is what this re-check buys. + c = callback; + if (c == null) { + pendingType = activityType; + pendingJson = userInfoJson; + return true; + } + } + } + try { + return c.continuationReceived(activityType, parse(userInfoJson)); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + /// The synced store changed on another of the user's devices. + public static void nativeSyncedStoreChanged() { + if (dceGuard) { + return; + } + ContinuityCallback c; + synchronized (LOCK) { + 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 = com.codename1.ui.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 { + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? new HashMap() : parsed; + } catch (Throwable t) { + Log.e(t); + return new HashMap(); + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index c8cfe878a4c..eb8043156c4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -493,6 +493,21 @@ public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBri return documentProviderBridge; } + private IOSContinuityBridge continuityBridge; + + @Override + public com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + // Only meaningful in builds that linked the continuity natives (CN1_USE_CONTINUITY, + // flipped by the builder when the app references com.codename1.continuity). Always + // returned: the bridge asks the native side once and answers honestly, and the native + // stubs to unsupported when the define is off. Returning null instead would also disable + // the on-device half of the framework, which needs no native support at all. + if (continuityBridge == null) { + continuityBridge = new IOSContinuityBridge(nativeInstance); + } + return continuityBridge; + } + private IOSIntentBridge intentBridge; @Override diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index bf685409486..2a5507d01a8 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -1372,6 +1372,45 @@ native void surfacesMirrorToWatch(String kindId, String timelineJson, */ native void intentsCompleteInvocation(String token, String resultJson); + // --- State restoration and continuity ----------------------------------- + // Backs com.codename1.continuity. Two unrelated Apple mechanisms sit behind these and are + // answered separately: NSUserActivity carries the current activity to a device that is + // physically nearby, and NSUbiquitousKeyValueStore carries a few durable values to every + // device on the account whether they are nearby or not. The first needs no entitlement and + // the second needs one, which is why com.codename1.continuity.sync is a package of its own. + // Payloads cross as JSON strings, matching the intents natives above. + + /** True when this build linked the continuation natives at all. */ + native boolean continuitySupported(); + + /** + * Advertises the current activity to the user's nearby devices, replacing whatever was + * advertised before. The JSON is the state; the title is what the receiving device shows. + */ + native void continuityPublish(String activityType, String title, String userInfoJson); + + /** Withdraws the advertised activity. */ + native void continuityClear(); + + /** True when this build linked the synced store and the entitlement granted one. */ + native boolean continuitySyncedStoreSupported(); + + /** Writes a value to the synced store, 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/Samples/samples/ContinuitySample/ContinuitySample.java b/Samples/samples/ContinuitySample/ContinuitySample.java new file mode 100644 index 00000000000..fa674c1f4a3 --- /dev/null +++ b/Samples/samples/ContinuitySample/ContinuitySample.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.samples; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.ContinuityListener; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.continuity.sync.SyncedStoreListener; +import com.codename1.ui.Button; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import java.util.HashMap; +import java.util.Map; + +/** + * Demonstrates {@code com.codename1.continuity}: keeping the user's work across a process death, + * and handing it to another device they own. + * + *

Deliberately without {@code @Route}. An app whose screens are declared with routes gets its + * navigation stack restored for free and shows nothing of the mechanism, which makes a poor + * demonstration -- so this one carries its whole state in the payload, which is also the harder + * of the two cases and the one that needs the code below.

+ * + *

To see it work in the simulator: type into the field, then use + * {@code Simulate -> Continuity -> Continue Here (As Another Device)}. On two Apple devices signed + * in to the same account, type on one and launch the app on the other.

+ */ +public class ContinuitySample { + + private Form current; + private Resources theme; + + /** The whole of this app's state. Read by the provider, written by the field. */ + private String draft = ""; + + /** Where the field was scrolled to, which is the sort of thing a route cannot carry. */ + private int caret; + + private TextArea field; + private Label status; + + public void init(Object context) { + theme = UIManager.initFirstTheme("/theme"); + Toolbar.setGlobalToolbar(true); + + // Installing a provider is what turns the framework on. Nothing before this line has any + // effect, which is what keeps an app that does not use continuity behaving as it always + // did. + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draft); + state.put("caret", Integer.valueOf(caret)); + return state; + } + + public void restoreState(Map state) { + Object savedDraft = state.get("draft"); + if (savedDraft instanceof String) { + draft = (String) savedDraft; + } + Object savedCaret = state.get("caret"); + // instanceof rather than a cast: a state that crossed from another device came + // through JSON, where every number is a Double, and a failed cast does not throw + // on the iOS virtual machine. + if (savedCaret instanceof Number) { + caret = ((Number) savedCaret).intValue(); + } + } + }); + + // Ask before moving the user. Jumping them somewhere without warning is the wrong default + // for anything they might be midway through, and holding the state is a one-liner. + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(final AppState state) { + String label = state.getTitle() == null ? "your other device" : state.getTitle(); + if (Dialog.show("Continue?", "Pick up \"" + label + "\"?", "Continue", "Stay")) { + Continuity.restore(state); + showDraftForm(); + } + // Consumed either way: the decision has been made here, so no other listener is + // asked and nothing is restored behind this one's back. + return false; + } + }); + + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + refreshStatus(); + } + }); + } + + public void start() { + if (current != null) { + current.show(); + return; + } + // "Restore, or else begin". This app records no routes, so restore() hands the payload to + // the provider and answers false -- the screen is still this app's to show. + Continuity.restore(); + showDraftForm(); + } + + public void stop() { + current = Display.getInstance().getCurrent(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + current = Display.getInstance().getCurrent(); + } + } + + public void destroy() { + } + + private void showDraftForm() { + Form form = new Form("Continuity", BoxLayout.y()); + + field = new TextArea(draft, 5, 40); + field.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + } + }); + form.add(new Label("Type something, then continue it elsewhere:")); + form.add(field); + + status = new Label(""); + form.add(status); + + Button checkpoint = new Button("Save a checkpoint now"); + checkpoint.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + capture(); + Dialog.show("Saved", "Advertised as \"" + Continuity.getTitle() + "\".", "OK", null); + } + }); + form.add(checkpoint); + + Button remember = new Button("Remember this device's choice"); + remember.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + // A write that reports whether it happened, because the store does not exist on + // most platforms and is finite where it does. + if (!SyncedStore.put("lastEditor", Display.getInstance().getPlatformName())) { + Dialog.show("No synced store", "This platform has none, so the choice stays " + + "on this device.", "OK", null); + } + refreshStatus(); + } + }); + form.add(remember); + + Button forget = new Button("Log out (forget everything)"); + forget.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + draft = ""; + caret = 0; + // The advertised activity outlives this screen, so an account's work would stay + // on offer to the devices around it without this. + Continuity.clear(); + field.setText(""); + refreshStatus(); + } + }); + form.add(forget); + + refreshStatus(); + form.show(); + } + + /** Reads the screen into the fields the provider reports, then checkpoints. */ + private void capture() { + draft = field.getText(); + caret = field.getCursorPosition(); + // A title names the WORK, not the screen: it is what another device shows the user before + // they accept. + Continuity.setTitle(draft.length() == 0 ? "An empty draft" + : "Draft: " + draft.substring(0, Math.min(24, draft.length()))); + Continuity.checkpoint(); + refreshStatus(); + } + + private void refreshStatus() { + if (status == null) { + return; + } + status.setText("continuation: " + (Continuity.isContinuationSupported() ? "yes" : "no") + + " | synced store: " + (SyncedStore.isSupported() ? "yes" : "no") + + " | last editor: " + SyncedStore.get("lastEditor", "none")); + if (status.getComponentForm() != null) { + status.getComponentForm().revalidate(); + } + } +} diff --git a/Samples/samples/ContinuitySample/codenameone_settings.properties b/Samples/samples/ContinuitySample/codenameone_settings.properties new file mode 100644 index 00000000000..a90409a0937 --- /dev/null +++ b/Samples/samples/ContinuitySample/codenameone_settings.properties @@ -0,0 +1,7 @@ +#Continuity sample build hints +# This sample touches com.codename1.continuity.sync, so the build asks for the iCloud key-value +# store entitlement -- which the App ID has to grant. Uncomment to drop it and leave SyncedStore +# reporting itself unsupported; handing work to a nearby device is unaffected either way. +#codename1.arg.ios.continuity.sync=false +# Set it to true instead to declare the store explicitly, which is what lets the signing +# preflight check the provisioning profile before a build is sent. diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java new file mode 100644 index 00000000000..0065ef289fe --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.continuity; + +import com.codename1.continuity.AppState; +import com.codename1.continuity.Continuity; +import com.codename1.continuity.ContinuityListener; +import com.codename1.continuity.RestStateRelay; +import com.codename1.continuity.StateProvider; +import com.codename1.continuity.sync.SyncedStore; +import com.codename1.continuity.sync.SyncedStoreListener; +import com.codename1.router.Navigation; +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; + +import java.util.HashMap; +import java.util.Map; + +/** + * Snippets that accompany the State Restoration and Continuity guide chapter. Each block between + * the tag markers is included verbatim into the AsciiDoc. + */ +public class ContinuitySnippets { + + /** Stands in for the screen the application is showing. */ + private TextArea draftField = new TextArea(); + + /** Stands in for the application's own session object. */ + private Session session = new Session(); + + /** A state the application held back rather than acting on immediately. */ + private AppState held; + + static class Session { + String getAccessToken() { + return "a-token"; + } + } + + // tag::provider[] + public void init(Object context) { + Continuity.setStateProvider(new StateProvider() { + public Map saveState() { + Map state = new HashMap(); + state.put("draft", draftField.getText()); + return state; + } + + public void restoreState(Map state) { + draftField.setText((String) state.get("draft")); + } + }); + } + // end::provider[] + + // tag::start[] + public void start() { + if (!Continuity.restore()) { + Navigation.navigate("/home"); + } + } + // end::start[] + + // tag::checkpoint[] + public void onDraftSaved() { + Continuity.setTitle("Draft to Dana"); + Continuity.checkpoint(); + } + // end::checkpoint[] + + // tag::askFirst[] + public void askBeforeMovingTheUser() { + Continuity.setAutoRestore(false); + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + held = state; + if (Dialog.show("Continue?", "Pick up \"" + state.getTitle() + + "\" from your other device?", "Continue", "Stay here")) { + Continuity.restore(held); + } + // Consumed either way: the decision has been made here. + return false; + } + }); + } + // end::askFirst[] + + // tag::relay[] + public void useMyOwnEndpoint() { + Continuity.setRelay(new RestStateRelay("https://api.example.com/continuity") { + @Override + protected String getToken() { + return session.getAccessToken(); + } + }); + } + // end::relay[] + + // tag::pollOnResume[] + public void onAppResumed() { + Continuity.pollRelay(); + } + // end::pollOnResume[] + + // tag::syncedStore[] + public String readSortOrder() { + return SyncedStore.get("sortOrder", "byName"); + } + + public void writeSortOrder(String order) { + if (!SyncedStore.put("sortOrder", order)) { + // No synced store here, or it is full. The value still has to live somewhere, so + // fall back to this device's own preferences rather than losing the choice. + com.codename1.io.Preferences.set("sortOrder", order); + } + } + // end::syncedStore[] + + // tag::syncedStoreListener[] + public void followTheStore() { + SyncedStore.addChangeListener(new SyncedStoreListener() { + public void storeChanged() { + // No values are carried, on any platform. Re-read what this screen shows. + applySortOrder(SyncedStore.get("sortOrder", "byName")); + } + }); + } + // end::syncedStoreListener[] + + // tag::capability[] + public void describeWhatThisDeviceCanDo() { + if (Continuity.isContinuationSupported()) { + showBanner("Open this app on your other device to carry on there."); + } + } + // end::capability[] + + // tag::logout[] + public void onLogout() { + Continuity.clear(); + } + // end::logout[] + + // tag::maxAge[] + public void expireACheckout() { + Continuity.setMaxAge(15 * 60 * 1000); + } + // end::maxAge[] + + private void applySortOrder(String order) { + } + + private void showBanner(String message) { + } +} diff --git a/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties b/docs/demos/common/src/main/snippets/developer-guide/state-restoration-and-continuity.properties new file mode 100644 index 00000000000..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..5a7acf8fbbc --- /dev/null +++ b/docs/developer-guide/State-Restoration-And-Continuity.asciidoc @@ -0,0 +1,303 @@ +== State Restoration and Continuity + +An app that's put in the background isn't paused. Android reclaims the process +routinely, iOS terminates a suspended app whenever it needs the memory, and in +both cases what comes back isn't the app the user left -- it's a fresh launch +that happens to be wearing the same icon. The user sees their work replaced by +the home screen and has no idea why. + +`com.codename1.continuity` saves what the user was doing and brings it back. On +Apple platforms it does one thing more: it offers that same work to the other +devices the person is signed in to, so a draft begun on the phone can be +finished on the iPad they pick up. + +The two halves cost different things, so they're two packages. +`com.codename1.continuity` holds the framework and everything that carries work +to a device the user is holding. `com.codename1.continuity.sync` holds a small +key/value store the platform keeps in step across their devices, and referencing +it earns an iOS build an entitlement. An app that wants the first shouldn't have +to arrange the second. + +[options="header"] +|=== +| Capability | iOS and macOS | Android | Simulator and desktop | JavaScript +| Restore after the process is killed | yes | yes | yes | yes +| Restore the `@Route` screen stack | yes | yes | yes | yes +| Carry on where they left off, on a device they're holding | yes | -- | simulated | -- +| A key/value store synced across devices | yes (`com.codename1.continuity.sync`) | -- | simulated | -- +| Carry state to any other device | your `StateRelay` | your `StateRelay` | your `StateRelay` | your `StateRelay` +|=== + +Branch on the capability queries -- `Continuity.isSupported()`, +`Continuity.isContinuationSupported()`, `SyncedStore.isSupported()` -- rather +than on platform detection. The first row is the one that matters most and it's +supported everywhere, because it's pure storage with no platform behind it. + +Every callback in this family arrives on the EDT. + +=== Six Things Worth Knowing Before You Design Around This + +*Nothing happens until you ask for it.* An app that never references this package +behaves exactly as it always did, and so does one that references it and never +calls `Continuity.setStateProvider` or `Continuity.enable`. `Continuity.restore()` +is never called for you either. Where restoration belongs in a launch is a +decision only the app can make, and a framework that guessed would be wrong for +the apps that care most. + +*The route stack is free; everything else is yours.* If your screens are declared +with `@Route`, the framework already knows the navigation stack and restores it +with no code from you. If your app navigates with `new MyForm().show()`, those +moves aren't addressable and there's nothing to write down -- so `restore()` +hands your payload to the `StateProvider` and answers `false`, leaving you to +show a screen. Both are supported; only the first is automatic. + +*Saving happens continuously, not at shutdown.* Every navigation marks the state +dirty and a checkpoint is written once per pass of the event loop, so by the time +the operating system suspends the app the work is already done. Don't look for a +place to save on exit; there isn't one worth using. Android blocks its own main +thread until your `stop()` returns, so an app that did its saving there would pay +for it on every suspend. Call `Continuity.checkpoint()` after changing something +your provider reports that no navigation touched. + +*A payload has to survive leaving the device.* It's written to disk, handed to an +operating system, and possibly delivered to a different device running a +different build of your app -- so it admits only `String`, `Integer`, `Long`, +`Double`, `Boolean`, and `List` and `Map` of those. Anything else is refused +where you produced it, with a message naming the key, rather than becoming a +value that stops arriving on the other end with nothing to say so. + +*Codename One runs no relay server.* Continuation between Apple devices is the +platform's; anything else -- iPhone to Android, two devices that are never in the +same room -- goes through a `StateRelay`, which is your endpoint. That isn't a +gap to be filled later. Deciding which saved states belong to the same *person* +is your account system's question, and a framework that answered it would be +guessing about your users. + +*A continuation isn't secure storage.* What you put in the payload crosses to +another device and is held by the operating system on the way. Tokens, keys and +anything you would not want restored on a device that merely shares an account +belong in `com.codename1.security.SecureStorage`, with the payload carrying at +most an identifier that means nothing on its own. + +=== Saving and restoring + +Two pieces. A `StateProvider` supplies the half the framework can't work out -- +the scroll position, the half-typed message, the record being edited -- and +installing one turns the framework on: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=provider,indent=0] +---- + +Then `start()` reads as "restore, or else begin": + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=start,indent=0] +---- + +`restore()` returns `true` when it put a screen up, so the caller knows not to +show its own. It returns `false` when there was nothing to restore *and* when the +state carried no routes -- the payload-only case above -- which is why the +fallback branch belongs there rather than behind a null check. + +`restoreState` runs before the restored screens are built, so a form the route +table is about to construct can read what the provider stashed while that form +is being built. + +Take a checkpoint by hand after a change no navigation followed: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=checkpoint,indent=0] +---- + +The title is what a receiving device may show the user before they accept, so it +should name the work rather than the screen -- `Draft to Dana`, not `Compose`. + +By default a saved state never expires, because an app the user opens after a +month should still come back where they left it. Where coming back is only +meaningful for a while -- a checkout, a booking hold, a queue position -- say so: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=maxAge,indent=0] +---- + +=== Continuing on another device + +Nothing extra is required for the Apple case. Every checkpoint advertises the +current state, and a device the user is holding is offered it by the system. What +you may want is to say so in the interface, which is what the capability query is +for: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=capability,indent=0] +---- + +An arriving state is restored automatically. When moving the user is a decision +your app should make -- they're midway through a payment, or the state belongs +to a different account than the one signed in here -- take it yourself. A +listener that returns `false` has consumed the state: nothing is restored and no +other listener is asked, which is what makes holding it and asking work: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=askFirst,indent=0] +---- + +A state this device published is never offered back to its own listener, and a +state already acted on is never acted on twice -- a continuation and a relay +routinely carry the same one. + +=== Reaching every other device + +A `StateRelay` is your endpoint, and `RestStateRelay` covers the common case: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=relay,indent=0] +---- + +Two requests against the one URL. A `POST` carries the state as a JSON body, +which you store against the signed-in user, replacing whatever you held for them. +A `GET` answers with the newest state you hold for that user, or an empty body +when you hold none. The JSON is a closed shape: your endpoint stores and returns +the document and never needs to look inside it. + +The token comes from `getToken()` rather than from the constructor because it's +read at every request, so a session that refreshes its token is followed with no +further code. + +A relay is written to when the app checkpoints and read only when something asks, +so ask when the app comes back to the foreground: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/continuity/ContinuitySnippets.java[tag=pollOnResume,indent=0] +---- + +On Android that call is already made for you when the activity resumes. Making it +yourself as well is harmless -- a state already seen is ignored. + +Put `Continuity.clear()` on your logout path. The advertised activity outlives +your app's own screen, so an account's work would otherwise stay on offer to the +devices around it after the user signed out -- 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] +---- + +=== 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 ef39d94799a..fd251c01eae 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -455,6 +455,15 @@ "SurfacesTimelineLogicTest" ] }, + { + "id": "state-restoration-continuity", + "category": "System surfaces", + "name": "State restoration and continuity", + "description": "Saves and restores what the user was doing, and checks the parts that have to behave identically on every port: the codec both wire formats share, the payload rule that lets a state cross to another device, the checkpoint, and the routeless restore that hands its payload back and shows nothing.", + "tests": [ + "ContinuityStateTest" + ] + }, { "id": "document-provider", "category": "System surfaces", diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 29a3d695493..d66b5bd5e5f 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,21 @@ 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) + .def("true") + .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 19d5a4247ad..819ec4e0e88 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1304,6 +1304,21 @@ private java.util.Set foldInCallAndVpnLibraryUsage( // extension, no Swift glue): the surfaces API compiles but answers unsupported at runtime. private boolean surfacesExtensionEnabled; + // Set when the app references com.codename1.continuity. Gates the CN1_USE_CONTINUITY native + // define and this app's entry in NSUserActivityTypes -- which is all continuation costs, + // there being no entitlement behind NSUserActivity handoff. + // + // Note what this does NOT gate: saving and restoring state on the device is pure Java over + // com.codename1.io.Storage and works in every build. Only the cross-device half is here. + private boolean usesContinuity; + + // Set when the app references com.codename1.continuity.sync -- deliberately narrower than + // usesContinuity, and for the reason usesHomeAccessoryData is narrower than usesSmartHome. + // The synced store is NSUbiquitousKeyValueStore, whose entitlement has to be granted on the + // App ID, so handing it to an app that only wanted to pass work to the tablet in the user's + // other hand would fail its codesigning for a capability it never asked for. + private boolean usesContinuitySync; + // Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native // define, the CN1Documents file provider extension and the app group that lets the two // processes meet. @@ -2630,6 +2645,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. @@ -4061,6 +4101,19 @@ public void usesClassMethod(String cls, String method) { replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_DOCUMENTS", "#define CN1_USE_DOCUMENTS"); } + // com.codename1.continuity usage compiles the NSUserActivity / NSUbiquitousKeyValueStore + // glue (gated by CN1_USE_CONTINUITY so other builds carry no such symbols), and opens + // the continuity branch in the app delegate. The define lives in the shared + // CodenameOne_GLViewController.h so it reaches every continuity translation unit, + // mirroring CN1_USE_INTENTS. + // + // Not gated on the sync opt-out below: the store reports its own availability at + // runtime from whether the entitlement actually granted one, and the continuation half + // needs these symbols regardless. + if (usesContinuity) { + replaceInFile(new File(buildinRes, "CodenameOne_GLViewController.h"), "//#define CN1_USE_CONTINUITY", "#define CN1_USE_CONTINUITY"); + } + // com.codename1.wearable usage compiles the WatchConnectivity glue (gated by // CN1_USE_WATCHCONNECTIVITY so other builds carry no WCSession symbols). The define // lives in the shared CodenameOne_GLViewController.h so it reaches every wearable @@ -5394,6 +5447,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 @@ -10875,7 +10960,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); } @@ -11542,6 +11631,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"); @@ -11549,6 +11653,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 @@ -11559,18 +11666,268 @@ static String userActivityTypesKey(List> intents) { return "\nNSUserActivityTypes" + types + ""; } + /// 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) { + return mergeUserActivityTypes(inject, intents, null); + } + + /// 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) { // 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"); @@ -11578,10 +11935,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; } @@ -14119,19 +14480,42 @@ 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. + 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); } } // CoreSpotlightContinuation is about Spotlight, not about App Intents, and gating it on 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..ac1d8337d0d 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,14 @@ 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; } /** A problem found before the build was sent: {@code message} is written for the user. */ @@ -222,6 +230,83 @@ 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. WHICH container it grants is not something this can answer from the key + // alone, so a project naming its own -- the shape of an app sharing a store with a + // sibling -- is where the check stops rather than warning on what it cannot check. + 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 +1090,11 @@ static Profile parse(byte[] raw) throws Exception { } } } + // Same nesting again: this is what says whether the profile can sign a target that asks + // for the iCloud key-value store, which a reference to com.codename1.continuity.sync + // makes the build declare. + Element ubiquity = valueForKey(doc, "com.apple.developer.ubiquity-kvstore-identifier"); + profile.ubiquityKeyValueStore = ubiquity != null; profile.type = deriveType(doc); return profile; } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java new file mode 100644 index 00000000000..25aadead9cd --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderContinuityPlistTest.java @@ -0,0 +1,551 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * {@code NSUserActivityTypes} has two contributors, and there can only be one key. + * + *

App intents and continuity both need this key, an app that uses both is ordinary, and a + * property list carrying it twice is one iOS reads unpredictably. The interesting cases are + * therefore all about the two meeting: each alone, both together, and both on top of an array the + * application already declared through {@code ios.plistInject}.

+ * + *

The failure this prevents is silent on both sides. iOS only continues an activity whose type + * the app declared here, so a missing entry is a feature that does nothing at all -- on two + * devices, with nothing logged anywhere.

+ */ +class IPhoneBuilderContinuityPlistTest { + + private static final String CONTINUITY_TYPE = "com.example.app.continuity"; + + private static Map intent(String id) { + Map m = new HashMap(); + m.put("id", id); + m.put("assistant", Boolean.TRUE); + return m; + } + + private static List> intents(String... ids) { + List> out = new ArrayList>(); + for (String id : ids) { + out.add(intent(id)); + } + return out; + } + + private static List> noIntents() { + return new ArrayList>(); + } + + private static int occurrences(String haystack, String needle) { + int count = 0; + int at = haystack.indexOf(needle); + while (at >= 0) { + count++; + at = haystack.indexOf(needle, at + needle.length()); + } + return count; + } + + // ------------------------------------------------------------------ + // Emitting the key + // ------------------------------------------------------------------ + + @Test + void continuityAloneDeclaresItsActivityType() { + String key = IPhoneBuilder.userActivityTypesKey(noIntents(), CONTINUITY_TYPE); + + assertTrue(key.contains("NSUserActivityTypes"), key); + assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key); + assertEquals(1, occurrences(key, "NSUserActivityTypes"), key); + } + + @Test + void intentsAndContinuityShareOneKey() { + String key = IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), CONTINUITY_TYPE); + + assertEquals(1, occurrences(key, "NSUserActivityTypes"), key); + assertEquals(1, occurrences(key, ""), key); + assertTrue(key.contains("logWorkout"), key); + assertTrue(key.contains("" + CONTINUITY_TYPE + ""), key); + } + + @Test + void intentsAloneAreUnchangedByTheContinuityParameter() { + assertEquals(IPhoneBuilder.userActivityTypesKey(intents("logWorkout")), + IPhoneBuilder.userActivityTypesKey(intents("logWorkout"), null)); + } + + /** + * An app with nothing to declare writes nothing. An empty array would state that the app + * continues no activity at all, into the plist of an app that may well continue its own. + */ + @Test + void nothingToDeclareWritesNoKey() { + assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), null)); + assertEquals("", IPhoneBuilder.userActivityTypesKey(noIntents(), "")); + } + + // ------------------------------------------------------------------ + // 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() { + 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() { + 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() { + 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() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + assertTrue(merged.contains("com.example.app.legacyHandoff"), merged); + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + } + + @Test + void intentsAndContinuityBothMergeIntoOneSuppliedArray() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), + CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + assertEquals(1, occurrences(merged, ""), merged); + assertTrue(merged.contains("com.example.app.legacyHandoff"), merged); + assertTrue(merged.contains("logWorkout"), merged); + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + } + + /** + * A project that already named the continuity type itself gets it once, not twice. + */ + @Test + void anAlreadyDeclaredContinuityTypeIsNotDuplicated() { + String inject = "NSUserActivityTypes" + + "" + CONTINUITY_TYPE + ""; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertEquals(1, occurrences(merged, "" + CONTINUITY_TYPE + ""), merged); + } + + @Test + void aFragmentWhoseArrayCannotBeFoundIsReturnedUnchanged() { + String inject = "NSUserActivityTypesnot an array"; + + assertEquals(inject, + IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE)); + } + + /** + * The parser has to accept the shapes a hand-written fragment really carries. + */ + @Test + void aSpacedClosingTagIsStillMergedInto() { + String inject = "NSUserActivityTypes" + + "com.example.app.legacyHandoff"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, noIntents(), CONTINUITY_TYPE); + + assertTrue(merged.contains("" + CONTINUITY_TYPE + ""), merged); + assertEquals(1, occurrences(merged, "NSUserActivityTypes"), merged); + } + + // ------------------------------------------------------------------ + // 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() { + 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() { + 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() { + 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); + } + + /** + * The documented behaviour when this key's value is not an array is to return the fragment + * untouched. An unbounded search instead reached past it and inserted the ids into a LATER + * key's array, corrupting a property this code was never asked about. + */ + @Test + void aNonArrayValueDoesNotBorrowALaterKeysArray() { + String inject = "NSUserActivityTypesnot an array" + + "SomethingElsekeep"; + + String merged = IPhoneBuilder.mergeUserActivityTypes(inject, intents("logWorkout"), + CONTINUITY_TYPE); + + assertEquals(inject, merged, "an unrelated array was edited"); + } + + @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() { + 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..b3c927c3284 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/IOSContinuitySyncPreflightTest.java @@ -0,0 +1,212 @@ +/* + * 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()); + } + + /** + * An app sharing a store with a sibling names that sibling's container. WHICH container a + * profile grants is not a question this can answer from the key alone, so a profile that + * grants the capability is left alone. + */ + @Test + public void anExplicitContainerOnAGrantingProfileIsLeftAlone() throws Exception { + Properties p = settings(profile("WithCloud", true)); + p.setProperty("codename1.arg.ios.entitlements.com.apple.developer" + + ".ubiquity-kvstore-identifier", "ABCD1234.com.example.shared"); + + assertTrue(check(p).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/continuity/AppStateWireTest.java b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java new file mode 100644 index 00000000000..6e4711149a9 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/AppStateWireTest.java @@ -0,0 +1,530 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.continuity; + +import com.codename1.io.Util; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The two wire formats an {@link AppState} has to survive, and the payload rule that makes both + * possible. + * + *

A state is written to storage on this device, handed to an operating system that may deliver + * it to another one, and sent through a relay to a device that may not be running the same build. + * Every one of those is lossy for something, so what is admitted into a payload is deliberately + * narrow -- and the point of these tests is that the narrowness is enforced where the application + * can act on it rather than discovered as a value that stopped arriving.

+ */ +public class AppStateWireTest { + + @Test + public void jsonRoundTripPreservesEveryField() throws Exception { + AppState state = sample(); + + AppState back = StateCodec.fromJson(StateCodec.toJson(state)); + + assertNotNull(back); + assertEquals(Arrays.asList("/home", "/users/42"), back.getRoutes()); + assertEquals("Ada", back.getPayload().get("name")); + assertEquals("device-a", back.getDeviceId()); + assertEquals("Editing Ada", back.getTitle()); + assertEquals(7L, back.getSequence()); + assertEquals(1700000000123L, back.getTimestamp()); + } + + /** + * The reason the sequence and timestamp are encoded as strings. + * + *

JSON has one number type and {@code JSONParser} reads every one of them back as a + * {@code Double}. A millisecond timestamp is past the range a double represents exactly, so a + * numeric encoding would come back changed -- and only on the relay path, leaving a state that + * no longer compares equal to the one the same device published through a continuation.

+ */ + @Test + public void aMillisecondTimestampSurvivesJsonExactly() throws Exception { + AppState state = new AppState().setTimestamp(1763512345678L).setSequence(9007199254740993L); + + AppState back = StateCodec.fromJson(StateCodec.toJson(state)); + + assertEquals(1763512345678L, back.getTimestamp()); + assertEquals(9007199254740993L, back.getSequence()); + } + + @Test + public void externalizableRoundTripPreservesEveryField() throws Exception { + Util.register(AppState.OBJECT_ID, AppState.class); + AppState state = sample(); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(bytes); + Util.writeObject(state, out); + out.close(); + Object read = Util.readObject( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + + assertTrue(read instanceof AppState); + AppState back = (AppState) read; + assertEquals(Arrays.asList("/home", "/users/42"), back.getRoutes()); + assertEquals("Ada", back.getPayload().get("name")); + assertEquals("device-a", back.getDeviceId()); + assertEquals("Editing Ada", back.getTitle()); + assertEquals(7L, back.getSequence()); + assertEquals(1700000000123L, back.getTimestamp()); + } + + /** + * 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. + */ + @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 is NOT validated. + * + *

It was validated where it was produced. Refusing it here would turn a remote build's + * mistake into an exception on this device, at a moment the user cannot connect to anything + * they did.

+ */ + @Test + public void anArrivingPayloadIsNotRevalidated() { + Map wire = new HashMap(); + Map payload = new HashMap(); + payload.put("odd", new Object()); + wire.put("payload", payload); + wire.put("device", "other"); + + AppState back = StateCodec.fromMap(wire); + + assertNotNull(back); + assertEquals("other", back.getDeviceId()); + } + + @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. + */ + @Test + public void aDocumentWithNoStateFieldsIsNotAState() throws Exception { + assertNull(StateCodec.fromJson("{}")); + assertNull(StateCodec.fromMap(new HashMap())); + assertNull(StateCodec.fromJson("{\"somethingElse\":1,\"unrelated\":\"x\"}")); + } + + /** 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"); + } +} 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..c0dfdd5fec8 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/continuity/LocalContinuityTest.java @@ -0,0 +1,1618 @@ +/* + * 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.continuity.sync.SyncedStoreListener; +import com.codename1.impl.continuity.LocalContinuityBridge; +import com.codename1.io.Storage; +import com.codename1.junit.EdtTest; +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.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.assertTrue; + +/** + * 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 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); + 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 + // ------------------------------------------------------------------ + + /** + * 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(); + } + long newest = Continuity.getRestorableState().getSequence(); + 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(); + 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) { + Continuity.pollRelay(); + try { + Thread.sleep(40); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + + 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 state fetched in one relay session must not be admitted in another. The poll used to + * validate the account era, release the lock and then deliver -- a check-then-act, so a + * clear() landing in the gap admitted the previous account's response under the new era and + * the freshly emptied lastSeen, and restored it into the account that had just signed in. + * The era travels with the state now and is asked again under the hold that records the mark. + */ + @EdtTest + public void aStateCarryingAForeignRelayEraIsNotAdmitted() { + Continuity.enable(); + final int[] seen = new int[1]; + Continuity.addContinuationListener(new ContinuityListener() { + public boolean stateReceived(AppState state) { + seen[0]++; + return true; + } + }); + + // An era this session has never been in: what a poll started before a logout carries. + Continuity.deliver(foreign("device-x", 3), 4242L); + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + try { + Thread.sleep(250); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + }); + + assertEquals(0, seen[0], + "a state from a previous relay session was delivered into this one"); + } + + /** + * 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"); + } + + /** + * 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(); + 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) { + try { + Thread.sleep(25); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + + 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() { + BlockingFetchRelay old = new BlockingFetchRelay(); + Continuity.enable(); + Continuity.setRelay(old); + 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) { + try { + Thread.sleep(20); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + + 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(); + } + r.awaitInFlight(); + r.release(); + 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 { + 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(); + + public void publish(AppState state) { + } + + 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 null; + } + + 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"); + } + + /** + * And re-enabling before the queue drains must not resurrect it, which is why this is a + * generation rather than a flag: an `enabled` test at dispatch time would pass here. + */ + @EdtTest + public void disablingAndReEnablingDoesNotResurrectAQueuedDelivery() { + RecordingProvider provider = new RecordingProvider(); + Continuity.setStateProvider(provider); + RecordingListener listener = new RecordingListener(); + Continuity.addContinuationListener(listener); + + Continuity.deliver(fromElsewhere("stale run", 1L)); + Continuity.disable(); + Continuity.enable(); + flushSerialCalls(); + + assertEquals(0, listener.calls, + "a delivery from the previous run survived disable/enable"); + } + + /** + * 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(); + r.awaitEntered(); + 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. + 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())); + } + + public AppState fetch() { + return null; + } + + 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. + 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(); + } + + 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/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/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 d0b69c97942..a57651956d0 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 @@ -518,6 +518,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..b41e5478932 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ContinuityStateTest.java @@ -0,0 +1,189 @@ +/* + * 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; + } + + @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. + String activityType = Continuity.getActivityType(); + assertBool(activityType != null && activityType.endsWith(".continuity"), + "activity type ends with .continuity"); + + 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"); + + Continuity.setTitle("cn1ss continuity"); + Continuity.checkpoint(); + + 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. + 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. + 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. + 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. + assertEqual("byName", SyncedStore.get("cn1ss.sortOrder", "byName"), + "an absent synced value answers with the default"); + boolean wrote = SyncedStore.put("cn1ss.sortOrder", "byDate"); + assertEqual(synced, wrote, "a synced write succeeds exactly where a store exists"); + if (wrote) { + assertEqual("byDate", SyncedStore.get("cn1ss.sortOrder", "byName"), + "a synced value reads back"); + SyncedStore.remove("cn1ss.sortOrder"); + } + assertBool(SyncedStore.keys() != null, "the key list is never null"); + + // 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 |